diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..00a51aff5 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# +# https://help.github.com/articles/dealing-with-line-endings/ +# +# These are explicitly windows files and should use crlf +*.bat text eol=crlf + diff --git a/.gitignore b/.gitignore index 05bbc6587..d7bb907eb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,21 @@ +# Ignore Gradle project-specific cache directory +.gradle + +.DS_Store + +# Ignore Gradle build output directory +build +out + +.idea + +# node plugin stuffs +node_modules + +.enable-composite-build +snapshot-tester.sh + + # Created by .ignore support plugin (hsz.mobi) ### Gradle template .gradle @@ -201,3 +219,5 @@ package-lock.json # embedded mysql stuff /target/ /.proxy-dash.pid.lock + +.vscode diff --git a/.travis.yml b/.travis.yml index 04e1e4900..ed8080566 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,16 +1,39 @@ language: java -jdk: -- openjdk8 -before_install: - - nvm install 8.12.0 +install: true + +os: linux +dist: bionic +jdk: openjdk11 + +script: + - "./gradlew check build buildDashboard generateAggregatedReports -x integrationTest --continue" + after_success: -- ./gradlew jacocoTestReport coveralls + - ./gradlew coveralls + - curl -F 'json_file=@build/coveralls/report.json' 'https://coveralls.io/api/v1/jobs' # https://github.com/kt3k/coveralls-gradle-plugin/issues/85#issuecomment-475958699 + - test "${TRAVIS_PULL_REQUEST}" == "false" && test "${TRAVIS_TAG}" != "" && ./gradlew bintrayUpload + +before_cache: + - rm -f $HOME/.gradle/caches/modules-2/modules-2.lock + - rm -fr $HOME/.gradle/caches/*/plugin-resolution/ + +cache: + directories: + - "$HOME/.gradle/caches/" + - "$HOME/.gradle/wrapper/" + deploy: - skip_cleanup: true provider: releases api_key: - secure: c4P+wrD6ABsSsl7sg7hW686igl5VXiJ6IMoHIh3BbpVA7BKnVlHCV4mHOSW0EZ7y2dOnNH+W8V6P3Yu39Pq3JybL3tZMc6KOZIxq2/7r47cHNakimhb2WhYPf8tly7xIYx31DlDc0OFJsasW9uB++utE72CYIGIMfFkkPw+glfufNkdaQX1Qg3rJ2yzqNPZt5l8cuzQkYfJKheddnN19DcxgQeci/jWYP2dLYfis8lowTNI1CjREf3iE87+ouTMpWjFAOoU9ojQSl6k2c3OKWN81cqPGqM/cgMyR0q/D/AnE1CcooMPqkxCPJP/WeB7NNUgajxWVdl5kdKKO1z86rA3fdFmHLdGxbcus1zZXrlJhRdkdI8TfLOEXXsleCHOpJlKn+ks9E/E8tPLL83AiClhMPtX8FWFzMpuMTSVMXWq3MmC7Uz6UbEa+94dv58abxskX83/k1z7/7y9vOTId9weEYJ+kacuFZDnnGFk7g62tT9lziWVZLSOUkLl2GGbH01g6xD1y5CVZRQZGSZVsChywGvCBJ/ntzngXjYnw1fh5b+789BoAiOC3nphLITZoXSNfBb8M/6ChjVIY1cjKqHyTzWIjB64vsPkMs3g2K9vPAWQGVma/dsNkNBZ6tBd4D5B8hrUsWifyaIjW0r0lEfSBfPKiy6xnwGivvHuqHSM= - file: build/libs/cms.jar + secure: rxii3NSGNU9OR0k8Udt5LqDV+yjPy5wq2gCLWE29c9IpUoT11B3QQq7CmkyPZKavjGZl+bhpHVTfDY6Hhy57saI+s7MsYHYA9foFv6843pYqA8FmeGL4LVMJDIxdFAgt0cG/a4zQIBMWGbgMqo36tjdNA2qkcBuVTP5Ve/9nigS0BVWZeBOtK0oFZoQ/M7d2hbOvVGIrnqQmWcYR9lNVP844YaF3YB20DinYPjmo/PtT+wMiRR7KdUTHmYi4pK6mIqBTUr+f9gkL2+cQWvNJg/6+gpC5QoCGVHmijrGfBODxnh9Ei5TjsPYp4iRAGvA8ljPOGxJwoGQW/Xyh3ADfBRhUPpe6tR3NENo4GVrOldYyhv59HEcKb/dyeHKXkcGaeocAikeBE5AkOOH0OAjsTyCVcm0kOOjkRHGngBAnuuTxv65hHOYdk7a//pZ6YKm/yTGJxf1X7Y1aNoAV7j3oyheqZW8Ht9AnPW5fBlsJbCAXlf0+u9sgbDTT0e4YCPinqBKIwG6qDhws0lQQTKDoahzS6NLDOBSlI3xZ+jtPYxmnJlMzzViQTxeyQR1h358diEUH+GUnlCXoCzDpt9OgbsxeJyxjPwzkzoVgGjLhbswaxjK9ab5LhrYDxv68ZiUxAU0mu0eYEwht6vstK0Br/EdnzdYZehydsd2Eaz0GQ5k= + file: + - cerberus-web/build/libs/cerberus.jar + - cerberus-web/build/libs/cerberus.jar.MD5 on: repo: Nike-Inc/cerberus - tags: true \ No newline at end of file + tags: true + +env: + global: + - secure: R8NA4hNA8SmxP7J0x3Nidv0GDnBctrTp0Wrd5s4VFP9+9SQdAgoulSoUgz69CuAJwmB/p0JtMFtd4LQfDeyL97u1SxfCDQFbuJf93Oj341ADOyK0p7dLBa3tmvquaCtWJqfx6GiR0Rp3WipGeyae/1nloMXltvA/gEKFQlingjXfwBERJ7aVWx2LBnuhU7QayhqEHb6AP/BSUtHBYBww7UG2xfpR1goFWp2XCCXElCot2efAxSh/mCiN3ZCn+kn3qs14lb/IM379U8In9XwAJuEdBV50Woawi+9j5cWdPo/+nCGlBM4csrR7rkz9MtGDUTTLN30xq9NkKsPeN5dYkuly/2JNSLyFIfGhsjSAeHqxB7Zw49o99pypERItmHKNzcE3WPzJrPhvHhm6T5Tf+4Rc7rUg3b5cZ6sl+/qonL19U30gF/dDJ/gdSFBULbYwh582Z0+0MrkCDkWBau/KPgt/zsDtWe8xqtKIQYPexlSCjyRujDDZCgwxx6k3AgS5mjjU/qqj6xjBLERy9IEwwe2J9mVfTfrYg5UXDxa2H+MSev1ZFDxWswhTfV/YmEk9biOADNcZI3VBnR2fUekklcPuEuI3qtxbY21iCN2Kwu38+vX+90NfS9jCfPnrY+OHtF9duSpOojPDQs+V/hcZ8wrbYAbf96itS3WZSbDSjZ8= + - secure: YYl4kXd0TKA11aEY8HDbtZmo1g6V2a/qQFUD96/2U+5LaYTyDtFqRL/My9PJqHGh7xmD5TvWhW2ZYRc3Uoh6+Zq4ekNjkk6hKWv01hHCcQo4DDaBNbqTi05+zjmMuA1X8wPS6RGd7rnOhmdpSWX5S66oQc1UjvNa6SJd1txxKHUqfu5g4V0XbKSC+ypHj54cvaOLUY+goQ4MJiZP29iAAzy1AH6gPONwxwi5i/++NNvvLesq0TgObQUUXh5j/nEw7WbjiYUCMmBbssqqUq9koZNyC/DDJbEvcHzpc8oCSzWLrGXVanhoD3Vu5DBUE/JEP18mjCGjkzSSF56nysYNm4QNtwXpx313D3f120GERjA2x81LcAcJc4UaUPNKLUxxRfbjILezJX0nmzK7UMmgegy81iGj7DrChw1BTjanVIXrXNRzZjjVcOgVxTw+Vdi+vpB+BGiEsB4RJKL5I2XGFVwvP8KVOek+OKyrrHP2MdalXteld2ShTwe8jsYbcojdAJ5eLqqYbq9cdCiYO2+wpzK1bB1Cz4FPXrxrBGL2a3bSASsh2h3bV13eOi6J7igeY4BMYqcfFJnxC9ZSBllqoei7f3eiNpDSJQZ3FI5JPTE5b1Hoc9QiaaP5x9cuSI9zbXajGSYd0ym5tAPei3R7NTBCwsesE/vELdTPSwkrR5I= diff --git a/Dockerfile.build b/Dockerfile.build new file mode 100644 index 000000000..34bad7b80 --- /dev/null +++ b/Dockerfile.build @@ -0,0 +1,7 @@ +# This docker image is for building the Cerberus artifacts, NOT for running the artifacts. + +FROM ubuntu:bionic + +RUN apt -y update +RUN apt -y upgrade +RUN apt install -y curl jq git openssh-client bash openjdk-11-jdk python make gcc build-essential g++ diff --git a/LICENSE.txt b/LICENSE.txt index 74d1ba2cf..826c385df 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -199,4 +199,4 @@ distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and - limitations under the License. \ No newline at end of file + limitations under the License. diff --git a/README.md b/README.md index 91ef3f263..6fd29e377 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,31 @@ -# Cerberus Management Service +# Cerberus [![][travis img]][travis] -[![Coverage Status](https://coveralls.io/repos/github/Nike-Inc/cerberus-management-service/badge.svg?branch=master)](https://coveralls.io/github/Nike-Inc/cerberus-management-service) +[![][coveralls img]][coveralls] [![][license img]][license] -The Cerberus Management Service (CMS) is a core component of the Cerberus [REST API](http://engineering.nike.com/cerberus/docs/architecture/rest-api) -that facilities user and AWS IAM role authentication and the management of Safe Deposit Boxes (SDBs), containers for secrets. +The Cerberus API is a cloud native, scalable Springboot application that can securely store application properties and files with robust auditing features. + +Cerberus has an accessible user interface that offers teams there own self service portal for mapping various principals (Users and Applications) to what we call a Safe Deposit Box. + +Safe Deposit Boxes can store properties (k,v pairs, json blobs, etc) and files (certificates, private key files, etc). + +Cerberus has a robust versioning and audit features built in, so you can see who is doing what and revert data in an SDB if needed. To learn more about Cerberus, please visit the [Cerberus website](http://engineering.nike.com/cerberus/). -## Getting Started +## Getting Started for local development + +### Configure Cerberus + +Cerberus will look in `~/.cerberus/` for additional springboot configuration. +You can configure a `cerberus-local.yaml` file there that has your local specific conf. + +See the [configuration section](#configuration) for details on required and optional configuration. -### Running with persistent data, +### Start Mysql -If you wish to persist data permanently you can install MySQL locally +You need to configure and run MySQL locally **MySQL Version 5.7** is required to run the application locally. @@ -30,250 +42,106 @@ You'll need to create a database and user for it. Run the following SQL against CREATE USER 'cms'@'localhost' IDENTIFIED BY ''; GRANT ALL ON cms.* TO 'cms'@'localhost'; + +### Ensure that you have AWS Credentials available -## Configuration - -### Configurable Properties - -There are a few parameters that need to be configured for CMS to run properly, they are defined in this table. - -property | required | notes ---------------------------- | -------- | ---------- -JDBC.url | Yes | The JDBC url for the mysql db -JDBC.username | Yes | The JDBC user name for the mysql db -JDBC.password | Yes | The JDBC JDBC.password for the mysql db -root.user.arn | Yes | The arn for the root AWS user, needed to make the KMS keys deletable. -admin.role.arn | Yes | The arn for an AWS user, needed to make the KMS keys deletable. -cms.role.arn | Yes | The arn for the Instance profile for CMS instances, so they can admin KMS keys that they create. -cms.admin.group | Yes | Group that user can be identified by to get admin privileges, currently this just enables users to access `/v1/metadata` see API.md -cms.admin.roles | No | Comma separated list of ARNs that can auth and access admin endpoints. -cms.auth.connector | Yes | The user authentication connector implementation to use for user auth. -cms.user.token.ttl | No | By default user tokens are created with a TTL of 1h, you can override that with this param -cms.iam.token.ttl | No | By default IAM tokens are created with a TTL of 1h, you can override that with this param -cms.kms.policy.validation.interval.millis.override | No | By default CMS validates KMS key policies no more than once per minute, you can override that with this param -cms.auth.token.hash.salt | Yes | The string value which CMS will use to salt auth tokens -cms.encryption.cmk.arns | Yes | Development AWS KMS CMK ARNs for use in local encryption of secrets -cms.event.processors.com.nike.cerberus.event.processor.LoggingEventProcessor | No | defaults to true, Boolean of whether or not to enable event logging, see #events below -cms.event.processors.com.nike.cerberus.event.processor.AuditLogProcessor | No | defaults to false, Boolean of whether or not to enable audit logging, see #events below -cms.audit.bucket="bucket-name" | No | [See Logging Event Processor below](https://github.com/Nike-Inc/cerberus-management-service/tree/feature/audit_logging#audit-log-processor) -cms.audit.bucket_region="bucket-region" | No | [See Logging Event Processor below](https://github.com/Nike-Inc/cerberus-management-service/tree/feature/audit_logging#audit-log-processor) -cms.user.groups.caseSensitive | YES | Property to enable/disable case-sensitive user AD group permission checks - -KMS Policies are bound to IAM Principal IDs rather than ARNs themselves. Because of this, we validate the policy at authentication time -to ensure that if an IAM role has been deleted and re-created, that we grant access to the new principal ID. -The API limit for this call is low, so the `cms.kms.policy.validation.interval.millis.override` property is used to throttle this validation. - -For local dev see the `Development` section. +Ensure Credentials are available as outlined in the [AWS Java Credentials page](https://docs.aws.amazon.com/sdk-for-java/v1/developer-guide/credentials.html), we use the default provider chain. -For deployed environments they are configured via the CLI, which will generate a props file and stuff it into S3 encrypted with KMS. +For local development you can use a tool such as [gimme-aws-creds](https://github.com/Nike-Inc/gimme-aws-creds) - cerberus --debug \ - -e demo \ - -r us-west-2 \ - create-cms-config \ - --admin-group cerberus-admins \ - -P cms.auth.connector=com.nike.cerberus.auth.connector.onelogin.OneLoginAuthConnector \ - -P auth.connector.onelogin.api_region=us \ - -P auth.connector.onelogin.client_id=$ONE_LOGIN_CLIENT_ID \ - -P auth.connector.onelogin.client_secret=$ONE_LOGIN_CLIENT_SECRET \ - -P auth.connector.onelogin.subdomain=nike - -See [Creating an environment](http://engineering.nike.com/cerberus/docs/administration-guide/creating-an-environment) for more information. - -CMS will download the props file at startup time and load the props into Guice. - -### Events - -Currently there is one event type, an Auditable Event, this is an event such as a user logging in successfully or failing to login. - - -See [AuditableEvent](https://github.com/Nike-Inc/cerberus-management-service/blob/master/src/main/java/com/nike/cerberus/event/AuditableEvent.java) for more information like the type of metadata that is collected about an event. - -These events are generated all over the system when a un-authenticated or authenticated principal does anything worth auditing. These events are sent to all registered event processors. -There are 2 processors included by default the Logging Event Processor and the Audit Log Processor see below for more information - -### Event Processors - -Event processors can be enabled or disabled via the CLI generated configuration -P parameters see above for more information. -in the default configuration the Logging Event Process is set to `cms.event.processors.com.nike.cerberus.event.processor.LoggingEventProcessor=true` and therefore enabled by default. -The convention for enabling or disabling a processor is `cms.event.processors.[CLASS PATH TO PROCESSOR]=true|false`, guice will then attempt to create an instace of the class at runtime. +```bash +$ gimme-aws-creds --profile cerberus +Using password from keyring for justin.field@nike.com +Multi-factor Authentication required. +token:software:totp( GOOGLE ) : Justin.Field@nike.com selected +Enter verification code: 111111 +writing role arn:aws:iam::111111111111:role/cerberus.admin.role to /Users/jfiel2/.aws/credentials +``` + +### Start Cerberus -#### Logging Event Processor +Cerberus is a Spring boot application and this project makes use of the Springboot gradle plugin. +You can start cerberus with gradle -This event processor is on by default and takes any event and calls asString() and logs it. +`./gradlew cerberus-web:bootRun` -#### Audit Log Processor +You can start it with a remote debugger -This event processor is special, as when it is enabled a custom logback appender is created that logs Auditable Events as flattened JSON to `[HOSTNAME]-audit.log` -These files are rolled every 5 minutes and if the following `-P` props are set `cms.audit.bucket="bucket-name", cms.audit.bucket_region="bucket-region"`, they will be sent to S3 every time they are rolled. -The way they are stored as flattened JSON and stored in S3 has been optimized to be used with AWS Athena so that queries can be made against the audit data. +`./gradlew cerberus-web:bootRun --debug-jvm` -The CLI has commands for creating the S3 Buckets, IAM Roles and permissions and setting up Athena and auto-populating the properties needed to enable. +You must build the dashboard once and after you make changes +`./gradlew cerberus-dashboard:buildDashboard cerberus-web:bootRun` +We have also including 2 convenience scripts that are nice because they give you pretty colors -### User Authentication +```bash +./run.sh +``` -#### Auth Connector Interface +This script builds the jar and starts the application listening but not breaking for a remote debugger on port 5006 -The User authentication contract is defined by the [AuthConnector](https://github.com/Nike-Inc/cerberus-management-service/blob/master/src/main/java/com/nike/cerberus/auth/connector/AuthConnector.java) interface. +```bash +./debug.sh +``` -The only included implementation of this interface targets -OneLogin. We expect to implement more connectors in the near future. +This script builds the jar and starts the application stopping automatically before spring initializes and waits for a remote debugger to attach on port 5006 -##### OneLogin Auth Connector +## Configuration -property | required | notes -------------------------------------- | -------- | ---------- -cms.auth.connector | Yes | com.nike.cerberus.auth.connector.onelogin.OneLoginAuthConnector -auth.connector.onelogin.api_region | Yes | `us` or `eu` -auth.connector.onelogin.client_id | Yes | The OneLogin API client id -auth.connector.onelogin.client_secret | Yes | The OneLogin API client secret -auth.connector.onelogin.subdomain | Yes | Your orgs OneLogin subdomain [xxxxx].onelogin.com +Take a look at the [master configuration](cerberus-web/src/main/resources/cerberus.yaml), which contains all the available options and default values. +A reasonable approach would be to copy this file and place it ~/.cerberus/cerberus-${envName} and remove the default values you do not wish to override and configure any options you desire. -**Assumption: The current implementation looks up group membership for a user via the member_of field on the getUserById API response.** +Remember that this is a Springboot app, so when you deploy it you can configure it like so. -##### Okta Auth Connector +```bash +LOG_DIR=/var/log/cerberus +LOG_OUT=${LOG_DIR}/stdout.log +LOG_ERR=${LOG_DIR}/stderr.log -Multi-factor authentication is only enabled if it is required in Okta for the authenticating user. +# configure the jvm by using export JVM_BEHAVIOR_ARGS +. /path/to/some/file/that/does/advanced/jvm/config/ -property | required | notes -------------------------------------- | -------- | ---------- -cms.auth.connector | Yes | com.nike.cerberus.auth.connector.okta.OktaAuthConnector -auth.connector.okta.api_key | Yes | The Okta API key -auth.connector.okta.base_url | Yes | The Okta base url (e.g. `"https://example.okta.com"` or `"https://example.oktapreview.com"`) +APP_SPECIFIC_JVM_ARGS="\ +-Dspring.profiles.active=prod \ +-Dspring.config.additional-location:/opt/cerberus/ \ -##### Okta MFA Auth Connector +java -jar \ + ${JVM_BEHAVIOR_ARGS} \ + ${APP_SPECIFIC_JVM_ARGS} \ + /opt/cerberus/cerberus-web.jar > ${LOG_OUT} 2> ${LOG_ERR} +``` -Multi-factor authentication is enabled for all users, even if it is not required in Okta. +In the above when the app starts it will look in the classpath and `/opt/cerberus/` for `cerberus.yml|yaml`, `cerberus-prod.yml|yaml` -property | required | notes -------------------------------------- | -------- | ---------- -cms.auth.connector | Yes | com.nike.cerberus.auth.connector.okta.OktaMFAAuthConnector -auth.connector.okta.api_key | Yes | The Okta API key -auth.connector.okta.base_url | Yes | The Okta base url (e.g. `"https://example.okta.com"` or `"https://example.oktapreview.com"`) +### First Secrets -## Development +You need to configure the first secrets, AKA the secrets that Cerberus needs to run. +When Cerberus was first released AWS Secrets Manager didn't exist, so we rolled out a solution based on encrypting props +files with KMS and storing them in S3 and downloading and decrypting them at runtime and merging the props in Guice. -First, a few properties must be configured in `src/main/resources/cms-local-overrides.conf` +With the new Springboot based Cerberus (Phoenix) you can use Kork-Secrets and AWS Secrets Manager. -You'll need a few pieces of information before you can run the application: +You can upload a binary file such as a cert via the following: -- The DB password you setup earlier -- The group that identifies which users are administrators -- The root user ARN for your AWS account -- The AWS IAM role ARN that represents administrators and CMS instances -- The authentication connector class that is used to authenticate users and get their group membership -- The string value which CMS will use to salt auth tokens -- Development AWS KMS CMK ARNs for use in local encryption of secrets +```bash +aws secretsmanager create-secret --name ${ENV}-cms-ssl-cert --secret-binary fileb://path/to/your/ssl/cert.pfx ``` - # Database connection details. - JDBC.url="jdbc:mysql://localhost:3306/cms?useUnicode=true&characterEncoding=utf8&useLegacyDatetimeCode=false&serverTimezone=UTC&useSSL=false" - JDBC.username="cms" - JDBC.password="" - - # Group that user can be identified by to get admin privileges. - cms.admin.group="" - - # AWS ARNs used when setting up KMS keys for IAM role authentication. - root.user.arn="arn:aws:iam:::root" - admin.role.arn="arn:aws:iam:::role/" - cms.role.arn="arn:aws:iam:::role/" - # Auth Connector - cms.auth.connector= +Update the cert in the future via the following: - # Encryption - cms.auth.token.hash.salt=changeMe - cms.encryption.cmk.arns="arn:aws:kms:::key/,arn:aws:kms:::key/" +```bash +aws secretsmanager update-secret --secret-id arn:aws:secretsmanager:us-west-2:111111:secret:${ENV}-cms-ssl-cert-xxxxx --secret-binary fileb://path/to/your/ssl/cert.pfx ``` -### Dashboard - -To debug/test changes to the dashboard, run each of the following tasks in new command line terminals (each are blocking tasks). - -Using Embedded MySQL: - -1. `gradlew startMysqlAndDashboard` - - Starts an embedded instance of MySql in the background - - Starts the Dashboard as a blocking process - - This task needs to be run as Admin in Windows, ensure that you start the IDE or Terminals as Admin - - Once you see `successfully started MySQL and Dashboard` proceed to next step -1. `gradlew runCMS` - - Starts CMS as a blocking process - - To debug, use the `debugCMS` gradle task instead and attach remote debugger to port 5005 - - You will need to make sure your env is set as described http://docs.aws.amazon.com/sdk-for-java/v1/developer-guide/credentials.html - - Now you should have a complete CMS system running locally. - -Above should work on Windows, Mac, and Linux. - -Using local instance of MySQL server: - -1. If setting up for the first time: - 1. `mysql` -- Run this command as an admin in your Terminal - 1. Run the following in the `mysql` console: - ``` - CREATE DATABASE IF NOT EXISTS cms; - CREATE USER 'cms'@'localhost' IDENTIFIED BY ''; - GRANT ALL ON cms.* TO 'cms'@'localhost’; - ``` -1. `mysql.server start` - - Starts MySql server -1. `gradlew runCMS` - - Starts CMS as a blocking process - - To debug, use the `debugCMS` gradle task instead and attach remote debugger to port 5005 - - You will need to make sure your env is set as described http://docs.aws.amazon.com/sdk-for-java/v1/developer-guide/credentials.html - - Now you should have a complete CMS system running locally. -1. `gradlew runDashboardAndReverseProxy` - - Runs the dashboard and reverse proxy to interact with CMS, which sometimes better than curling or using postman. - - Runs an express server and reverse proxy to expose `http://localhost:9001/dashboard/` - - By default this command runs the dashboard through Webpack. You can run the Dashboard from the CMS jar by changing the `loadDashboardFromCms` variable in `dashboard/server.js` to true - -Above should work on Windows, Mac, and Linux. - -### Cerberus Management Service (CMS) - -To debug/test changes to CMS, run each of the following tasks in new command line terminals (each are blocking tasks). +Once uploaded you can reference the first secrets in the config yaml like this -Using Embedded MySQL: - -1. `gradlew startMysqlAndCms` - - Starts an embedded instance of MySql in the background - - Starts CMS as a blocking process - - This task needs to be run as Admin in Windows, ensure that you start the IDE or Terminals as Admin - - Once you see `successfully started MySQL and CMS` proceed to next step - -Above should work on Windows, Mac, and Linux. - -Using local instance of MySQL server: - -1. If setting up for the first time: - 1. `mysql` -- Run this command as an admin in your Terminal - 1. Run the following in the `mysql` console: - ``` - CREATE DATABASE IF NOT EXISTS cms; - CREATE USER 'cms'@'localhost' IDENTIFIED BY ''; - GRANT ALL ON cms.* TO 'cms'@'localhost’; - ``` -1. `mysql.server start` - - Starts MySql server -1. `gradlew runCMS` - - Starts CMS as a blocking process - - To debug, use the `debugCMS` gradle task instead and attach remote debugger to port 5005 - - You will need to make sure your env is set as described http://docs.aws.amazon.com/sdk-for-java/v1/developer-guide/credentials.html - - Now you should have a complete CMS system running locally. - - Navigate to http://localhost:8080/dashboard in your browser to log in - -Above should work on Windows, Mac, and Linux. - - -## Setting up your IDE - -Import the build.gradle file. - -## API documentation - -See [API.md](API.md) +```yaml +security.requireSsl: true +server.ssl: + keyStore: encryptedFile:secrets-manager!r:some-region!s:${ENV}-cms-ssl-cert + keyStorePassword: encrypted:secrets-manager!r:some-region!s:some-secret!k:some-key +``` ## License @@ -284,3 +152,6 @@ Cerberus Management Service is released under the [Apache License, Version 2.0]( [license]:LICENSE.txt [license img]:https://img.shields.io/badge/License-Apache%202-blue.svg + +[coveralls]:https://coveralls.io/github/Nike-Inc/cerberus +[coveralls img]:https://coveralls.io/repos/github/Nike-Inc/cerberus/badge.svg?branch=master diff --git a/build-and-deploy-cerberus-build-image.sh b/build-and-deploy-cerberus-build-image.sh new file mode 100755 index 000000000..0d6a81791 --- /dev/null +++ b/build-and-deploy-cerberus-build-image.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -e + +docker login +docker build -f Dockerfile.build -t cerberusoss/cerberus-build-image:latest . +docker push cerberusoss/cerberus-build-image diff --git a/build.gradle b/build.gradle index d55cb0752..dae9ddfc8 100644 --- a/build.gradle +++ b/build.gradle @@ -1,11 +1,11 @@ /* - * Copyright (c) 2016 Nike, Inc. + * Copyright (c) 2020 Nike, inc. * - * Licensed under the Apache License, Version 2.0 (the "License"); + * Licensed under the Apache License, Version 2.0 (the "License") * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, @@ -14,146 +14,235 @@ * limitations under the License. */ -import com.typesafe.config.ConfigFactory +import com.github.spotbugs.SpotBugsTask +import com.netflix.spinnaker.gradle.codestyle.SpinnakerCodeStylePlugin buildscript { - apply from: file('gradle/buildscript.gradle'), to: buildscript + ext { + versions = [ + lombok: '1.18.10', + resilience4j: '1.1.0', + awsSdkVersion: '1.11.688', + kork: '6.22.1', + guava: '28.1-jre', + springBoot: springBootVersion, + ] + } + + repositories { + maven { + url "https://dl.bintray.com/spinnaker/gradle" + } + maven { + url "https://plugins.gradle.org/m2/" + } + } + + dependencies { + classpath "org.owasp:dependency-check-gradle:5.2.4" + classpath "com.github.spotbugs:spotbugs-gradle-plugin:3.0.0" + classpath 'com.netflix.spinnaker.gradle:spinnaker-dev-plugin:7.1.2' + classpath "com.jfrog.bintray.gradle:gradle-bintray-plugin:1.8.4" + } } plugins { - id "org.flywaydb.flyway" version "4.0.3" - id "com.moowork.node" version "1.2.0" + id "io.spring.dependency-management" version "1.0.8.RELEASE" + id "com.github.kt3k.coveralls" version "2.9.0" } -apply plugin: 'java' -apply plugin: 'maven' -apply plugin: 'com.github.johnrengelman.shadow' -apply plugin: 'embedded-mysql' -apply plugin: 'com.wiredforcode.spawn' - -sourceCompatibility = 1.8 -targetCompatibility = 1.8 - -ext { - SpotBugsTask = com.github.spotbugs.SpotBugsTask +apply from: 'gradle/owasp-dependency-check.gradle' + +allprojects { + apply plugin: 'java' + apply plugin: 'groovy' + apply plugin: 'java-library' + apply plugin: 'jacoco' + apply plugin: SpinnakerCodeStylePlugin + apply plugin: 'com.github.spotbugs' + + dependencies { + spotbugsPlugins 'com.h3xstream.findsecbugs:findsecbugs-plugin:1.10.1' + } + + spotbugs { + toolVersion = '3.1.12' + sourceSets = [ sourceSets.main ] + excludeFilter = file("${rootProject.projectDir}/findbugs-supressions.xml") + } + + // To generate an HTML report instead of XML + tasks.withType(SpotBugsTask) { + reports.xml.enabled = false + reports.html.enabled = true + } + + repositories { + jcenter() + mavenCentral() + } + + jacoco { + toolVersion = "0.8.5" + } } -apply from: file('gradle/dependencies.gradle') -apply from: file('gradle/check.gradle') -apply from: file('gradle/integration.gradle') - -group = groupId // Necessary for the maven install task to function correctly +subprojects { + apply plugin: 'io.spring.dependency-management' + + apply from: file("${rootProject.projectDir}/gradle/bintray.gradle") + + sourceCompatibility = '11' + + sourceSets { + integrationTest { + java { + compileClasspath += main.output + test.output + runtimeClasspath += main.output + test.output + srcDir file('src/integration-test/java') + } + groovy { + compileClasspath += main.output + test.output + runtimeClasspath += main.output + test.output + srcDir file('src/integration-test/groovy') + } + resources.srcDir file('src/integration-test/resources') + } + } + + configurations { + integrationTestCompile.extendsFrom testImplementation + integrationTestRuntime.extendsFrom testRuntime + } + + //noinspection GroovyAssignabilityCheck + task integrationTest(type: Test) { + description = 'Runs the integration tests.' + testClassesDirs = sourceSets.integrationTest.output.classesDirs + classpath = sourceSets.integrationTest.runtimeClasspath + } + + dependencyManagement { + imports { + mavenBom("org.springframework.boot:spring-boot-dependencies:${versions.springBoot}") + } + } + + dependencies { + // Lombok + compileOnly "org.projectlombok:lombok:${versions.lombok}" + annotationProcessor "org.projectlombok:lombok:${versions.lombok}" + + // common test deps + testImplementation 'org.codehaus.groovy:groovy-all:2.5.7' + testImplementation 'org.spockframework:spock-core:1.3-groovy-2.5' + testImplementation 'junit:junit:4.12' + testImplementation group: 'org.mockito', name: 'mockito-all', version: '1.10.19' + testImplementation 'com.openpojo:openpojo:0.8.13' + } + + test { + testLogging { + events "passed", "skipped", "failed" + } + } -shadowJar { - classifier = '' - version = '' + integrationTest { + testLogging { + showStandardStreams = true + } + } } -build { - try { - // Create a *-local-overrides.conf properties file if it doesn't already exist. - def filePath = project.getRootDir().getAbsolutePath() - filePath += "/src/main/resources" - String filename = "$rootProject.name-local-overrides.conf" - File localPropertiesOverrideFile = new File("$filePath/$filename") - if (!localPropertiesOverrideFile.exists()) { - println("The $filename file does not exist. Creating an empty file for it at location: " + localPropertiesOverrideFile.getAbsolutePath()) - boolean success = localPropertiesOverrideFile.createNewFile() - if (success) - localPropertiesOverrideFile << "# Add local environment overrides for your machine here - this is not checked into git, so you can set any properties you want without affecting other people\n\n" - else - println("Unable to create the $filename file at location " + localPropertiesOverrideFile.getAbsolutePath() + ". Please create it manually") - } - } catch (Throwable t) { - logger.lifecycle("Failed to create local conf", t) - } +List blackList = [ + 'cerberus-api-tests' +] +def publishedProjects = subprojects.findAll { !blackList.contains(it.path) } + +task jacocoMerge(type: JacocoMerge) { + publishedProjects.each { subproject -> + executionData subproject.tasks.withType(Test) + } + doFirst { + executionData = files(executionData.findAll { it.exists() }) + } } -processResources{ - dependsOn 'copyDashboardResources' +task aggregatedJacocoReport(type: JacocoReport, group: 'Coverage reports') { + description = 'Generates an aggregate report from all subprojects' + dependsOn publishedProjects.test, jacocoMerge - doFirst { - project.ext.puppetRepository="maven" - project.ext.packaging="jar" - logger.lifecycle("The Executable jar will be created with name ${rootProject.name}.jar") - } + additionalSourceDirs.from = files(publishedProjects.sourceSets.main.allSource.srcDirs) + sourceDirectories.from = files(publishedProjects.sourceSets.main.allSource.srcDirs) + classDirectories.from = files(publishedProjects.sourceSets.main.output) + executionData jacocoMerge.destinationFile - // Copy all the files under ami. The default is resources - from "src/main/ami" + reports { + html.enabled = true // human readable + xml.enabled = true // required by coveralls + } } -test { - systemProperty '@appId' , rootProject.name - systemProperty '@environment' , 'local' +coveralls { + sourceDirs = publishedProjects.sourceSets.main.allSource.srcDirs.flatten() + jacocoReportPath = "${buildDir}/reports/jacoco/aggregatedJacocoReport/aggregatedJacocoReport.xml" + saveAsFile = true + sendToCoveralls = false } -artifacts { - archives shadowJar +configurations { + antJUnit } -jar { - dependsOn 'antReplace', 'copyDashboardResources' - doFirst { - archiveName 'cms.jar' - } +dependencies { + antJUnit 'org.apache.ant:ant-junit:1.9.7' +} - manifest { - attributes( - 'Class-Path': configurations.compile.collect { it.getName() }.join(' '), - 'Main-Class': 'com.nike.cerberus.Main' - ) +// Compile all the test results into a single one. +task aggregatedJunitXml { + ant.taskdef(name: 'junitreport', classname: 'org.apache.tools.ant.taskdefs.optional.junit.XMLResultAggregator', classpath: configurations.antJUnit.asPath) + dependsOn subprojects*.test + doFirst { + ///reports/jacoco/test/jacocoTestReport.xml + mkdir "$buildDir/reports/jacoco/test" + ant.junitreport(todir: "$buildDir/reports/jacoco") { + subprojects.each { + if (it.testResultsDir.exists()) { + fileset(dir: it.testResultsDir) + } + } } + } } -def config = ConfigFactory.parseFile(new File("${project.getRootDir().absolutePath}${File.separator}src${File.separator}main${File.separator}resources${File.separator}cms-local-overrides.conf")) - -project.ext { - flyway.user = config.hasPath('JDBC.username') ? config.getString('JDBC.username') : 'NOT_SET' - flyway.password = config.hasPath('JDBC.password') ? config.getString('JDBC.password') : 'NOT_SET' - flyway.url = config.hasPath('JDBC.url') ? config.getString('JDBC.url') : 'NOT_SET' +task aggregatedJunitHtml(type: TestReport) { + dependsOn subprojects*.test + destinationDir = file("$buildDir/reports/allTests") + // Include the results from the `test` task in all sub projects + reportOn subprojects*.test } -flyway { - schemas = ["cms"] - locations = ["classpath:com/nike/cerberus/migration"] +task generateAggregatedReports(dependsOn: [ + aggregatedJunitXml, + aggregatedJunitHtml, + aggregatedJacocoReport, + dependencyCheckAggregate +]) { + doLast { + println "Finished generating aggregated reports" + } } -task antReplace() { - dependsOn 'processResources' - doLast { - def releaseVersion = version - // Add version information to the app's main properties file - ant.replace(file: "$buildDir/resources/main/" + rootProject.name + ".conf", token: "@@RELEASE@@", value: releaseVersion) - - // Replace version and appname info in logback.groovy so that log messages will be tagged with the correct values. - ant.replace(file: "$buildDir/resources/main/logback.groovy", token: "@@RELEASE@@", value: releaseVersion) - ant.replace(file: "$buildDir/resources/main/logback.groovy", token: "@@APPNAME@@", value: artifactId) - - // Placeholder substitution in packer and puppet files. - def packerFilesForAntReplace = [ - "$buildDir/resources/main/packer/app-specific-jvm-args.sh" - ] - for (String fileName : packerFilesForAntReplace) { - ant.replace(file: fileName, token: "@@SERVICE_NAME@@", value: rootProject.name) - ant.replace(file: fileName, token: "@@ARCHIVE_NAME@@", value: "${rootProject.name}.jar") - } - } +task aggregatedTest { + dependsOn subprojects*.test } -task buildDashboard(type: NpmTask, dependsOn: npmInstall) { - doFirst { - project.buildDir.mkdirs() - } - group 'build' - args = ['run', 'build'] +task aggregatedCheck { + dependsOn subprojects*.check } -task copyDashboardResources(type: Copy, dependsOn: buildDashboard) { - def dashboardBuildDirectory = "${project.getRootDir().absolutePath}${File.separator}dashboard${File.separator}build" - def dashboardResourceFolder = "${project.getRootDir().absolutePath}${File.separator}src${File.separator}main${File.separator}resources${File.separator}dashboard" - - from dashboardBuildDirectory - into dashboardResourceFolder +task aggregatedClean { + dependsOn subprojects*.clean } -apply from: file('gradle/develop.gradle') \ No newline at end of file +defaultTasks ':cerberus-web:bootRun' diff --git a/cerberus-api-tests/README.md b/cerberus-api-tests/README.md new file mode 100644 index 000000000..d658e8590 --- /dev/null +++ b/cerberus-api-tests/README.md @@ -0,0 +1,205 @@ +# Cerberus API Tests + +All the tests require that the following environment variable be set + +Environment Variable | Description +-------------------- | ------------------ +CERBERUS_API_URL | The Cerberus API URL to Test + +When caching is enabled, you may also configure the following environment variable to get around caching + +Environment Variable | Default Value | Description +-------------------- | ------------------ +SLEEP_IN_MILLISECONDS | 0 | Milliseconds to sleep before executing frequently used API actions + +The tests also require that you manually set up an integration test SDB in the environment you are testing. +(TODO This can probably be automated away with TestNG before suite) + +The SDB needs to be configured as follows + +Field | Value +----------- | ----------------------------------------------------------------- +Name | Cerberus Integration Tests SDB +Category | Applications +Owner | some-group-you-belong-to +Description | This SDB is used for integration testing, do not delete this SDB + +User Group Permissions + +User Group | Role +------------------------------- | ------- +some-group-your-auto-user-is-in | write + + +IAM Role Permissions + +IAM Principal ARN | Role +---------------------------------------------- | ----- +arn:aws:iam::1111111:role/cerberus-api-tester | write + + +Secure Data (add the following Secure Data, after the SDB is created) + +Field | Value +----------- | ----------------------------------------------------------------- +Path | default-test-secret +Key | default-test-key +Value | default-test-value + +This sub module contains API tests that can validate the composed Cerberus API + +### IAM Principal API Tests + +This is a series of tests that validate that IAM authenticated Cerberus principals can interact with the Cerberus API +in the manner that is expected + +The following environment variables are required to run this test + +Environment Variable | Description +---------------------|--------------------------- +TEST_ACCOUNT_ID | The account id to use when authenticating with Cerberus using the IAM Auth endpoint +TEST_ROLE_NAME | The role name to use when authenticating with Cerberus using the IAM Auth endpoint +TEST_REGION | The region to use when authenticating with Cerberus using the IAM Auth endpoint + +You can run this only these tests with the following command + + CERBERUS_API_URL=http://127.0.0.1:9000 \ + TEST_ACCOUNT_ID=11111111 \ + TEST_ROLE_NAME=cerberus-api-tester \ + TEST_REGION=us-west-2 \ + gradlew clean -Dtest.single=CerberusIamApiTests cerberus-api-tests:integrationTest + +### IAM Principal API V2 Tests + +This is a series of tests that validate that IAM authenticated Cerberus principals can interact with the Cerberus API +in the manner that is expected + +The following environment variables are required to run this test + +Environment Variable | Description +TEST_ACCOUNT_ID | The account id to use when authenticating with Cerberus using the IAM Auth endpoint +TEST_ROLE_NAME | The role name to use when authenticating with Cerberus using the IAM Auth endpoint +TEST_REGION | The region to use when authenticating with Cerberus using the IAM Auth endpoint + +You can run this only these tests with the following command + + CERBERUS_API_URL=http://127.0.0.1:9000 \ + TEST_ACCOUNT_ID=11111111 \ + TEST_ROLE_NAME=cerberus-api-tester \ + TEST_REGION=us-west-2 \ + gradlew clean -Dtest.single=CerberusIamApiV2Tests cerberus-api-tests:integrationTest + +### User Principal API Tests + +This is a series of tests that validate that user authenticated Cerberus principals can interact with the Cerberus API +in the manner that is expected + +The following environment variables are required to run this test + +Environment Variable | Description +----------------------- | ------------------------------------------------------------------ +TEST_USER_EMAIL | The email address for a test user for testing user based endpoints +TEST_USER_PASSWORD | The password for a test user for testing user based endpoints +TEST_USER_OTP_SECRET | The secret for the test users OTP MFA (OTP == Google auth) +TEST_USER_OTP_DEVICE_ID | The device id for the test users OTP MFA (OTP == Google auth) + +You can run this only these tests with the following command + + CERBERUS_API_URL=http://127.0.0.1:9000 \ + TEST_USER_EMAIL=cerberus-automated-test-user@nike.com \ + TEST_USER_PASSWORD=${PASSWORD} \ + TEST_USER_OTP_SECRET=${OTP_SECRET} \ + TEST_USER_OTP_DEVICE_ID=111111 \ + gradlew clean -Dtest.single=CerberusUserApiTests cerberus-api-tests:integrationTest + +### Admin API Tests + +This is a series of tests that validate that an admin can call admin-specific endpoints + +Note: These tests are disabled by default and need to be enabled and triggered manually + +The following environment variables are required to run this test + +Environment Variable | Description +TEST_ADMIN_CERBERUS_TOKEN | A Cerberus auth token with permissions to call admin endpoints + +You can run this only these tests with the following command + + CERBERUS_API_URL=http://127.0.0.1:9000 \ + TEST_ADMIN_CERBERUS_TOKEN=0000-0000-0000-0000 + gradlew clean -Dtest.single=AdminApiTests cerberus-api-tests:integrationTest + +### Invalid Auth API Tests + +This is a series of tests that neither users nor IAM roles can make calls without a valid auth token + +You can run this only these tests with the following command + + CERBERUS_API_URL=http://127.0.0.1:9000 \ + gradlew clean -Dtest.single=FailedAuthenticationApiTests cerberus-api-tests:integrationTest + +### Negative User Permissions API Tests + +This is a series of tests that validate read and write users cannot perform actions in Cerberus beyond what is allowed +by their role permissions + +The following environment variables are required to run this test + +Environment Variable | Description +----------------------- | ------------------------------------------------------------------ +TEST_ACCOUNT_ID | The account id to use when authenticating with Cerberus using the IAM Auth endpoint +TEST_ROLE_NAME | The role name to use when authenticating with Cerberus using the IAM Auth endpoint +TEST_REGION | The region to use when authenticating with Cerberus using the IAM Auth endpoint +TEST_USER_EMAIL | The email address for a test user for testing user based endpoints +TEST_USER_PASSWORD | The password for a test user for testing user based endpoints +TEST_USER_OTP_SECRET | The secret for the test users OTP MFA (OTP == Google auth) +TEST_USER_OTP_DEVICE_ID | The device id for the test users OTP MFA (OTP == Google auth) + +**Note: In these tests, the test user is given limited permissions to the SDB, while the IAM principal retains +(ownership) permission to delete/clean up any test artifacts created in Cerberus. Thus both IAM principal and user +credentials are required + +You can run this only these tests with the following command + + CERBERUS_API_URL=http://127.0.0.1:9000 \ + TEST_ACCOUNT_ID=11111111 \ + TEST_ROLE_NAME=cerberus-api-tester \ + TEST_REGION=us-west-2 \ + TEST_USER_EMAIL=cerberus-automated-test-user@nike.com \ + TEST_USER_PASSWORD=${PASSWORD} \ + TEST_USER_OTP_SECRET=${OTP_SECRET} \ + TEST_USER_OTP_DEVICE_ID=111111 \ + gradlew clean -Dtest.single=NegativeUserPermissionsApiTests cerberus-api-tests:integrationTest + +### Negative IAM Permissions API Tests + +This is a series of tests that validate read and write IAM principals cannot perform actions in Cerberus beyond what is +allowed by their role permissions + +The following environment variables are required to run this test + +Environment Variable | Description +----------------------- | ------------------------------------------------------------------ +TEST_ACCOUNT_ID | The account id to use when authenticating with Cerberus using the IAM Auth endpoint +TEST_ROLE_NAME | The role name to use when authenticating with Cerberus using the IAM Auth endpoint +TEST_REGION | The region to use when authenticating with Cerberus using the IAM Auth endpoint +TEST_USER_EMAIL | The email address for a test user for testing user based endpoints +TEST_USER_PASSWORD | The password for a test user for testing user based endpoints +TEST_USER_OTP_SECRET | The secret for the test users OTP MFA (OTP == Google auth) +TEST_USER_OTP_DEVICE_ID | The device id for the test users OTP MFA (OTP == Google auth) + +**Note: In these tests, the test IAM principal is given limited permissions to the SDB, while the user retains +(ownership) permission to delete/clean up any test artifacts created in Cerberus. Thus both IAM principal and user +credentials are required + +You can run this only these tests with the following command + + CERBERUS_API_URL=http://127.0.0.1:9000 \ + TEST_ACCOUNT_ID=11111111 \ + TEST_ROLE_NAME=cerberus-api-tester \ + TEST_REGION=us-west-2 \ + TEST_USER_EMAIL=cerberus-automated-test-user@nike.com \ + TEST_USER_PASSWORD=${PASSWORD} \ + TEST_USER_OTP_SECRET=${OTP_SECRET} \ + TEST_USER_OTP_DEVICE_ID=111111 \ + gradlew clean -Dtest.single=NegativeIamPermissionsApiTests cerberus-api-tests:integrationTest diff --git a/cerberus-api-tests/cerberus-api-tests.gradle b/cerberus-api-tests/cerberus-api-tests.gradle new file mode 100644 index 000000000..6af2f4a35 --- /dev/null +++ b/cerberus-api-tests/cerberus-api-tests.gradle @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +apply plugin: 'groovy' +apply plugin: 'java' + +repositories { + jcenter() +} + +dependencies { + compile project(':cerberus-core').sourceSets.test.output + compile group: 'org.testng', name: 'testng', version: '6.14.3' + compile 'junit:junit:4.12' + compile 'org.slf4j:slf4j-api:1.7.21' + compile group: 'org.codehaus.groovy', name: 'groovy-all', version: '2.5.9' + compile 'io.rest-assured:rest-assured:4.1.2' + compile group: 'io.rest-assured', name: 'json-schema-validator', version: '4.1.2' + compile group: 'io.rest-assured', name: 'json-path', version: '4.1.2' + compile group: 'io.rest-assured', name: 'xml-path', version: '4.1.2' + compile group: 'org.hamcrest', name: 'hamcrest', version: '2.2' + compile "com.google.guava:guava:${versions.guava}" + compile group: 'com.amazonaws', name: 'aws-java-sdk-kms', version: '1.11.76' + compile group: 'com.amazonaws', name: 'aws-java-sdk-sts', version: '1.11.76' + compile group: 'org.jboss.aerogear', name: 'aerogear-otp-java', version: '1.0.0' + compile group: 'org.apache.commons', name: 'commons-lang3', version: '3.5' + compile group: 'com.thedeanda', name: 'lorem', version: '1.2' + compile "jakarta.xml.bind:jakarta.xml.bind-api:2.3.2" + compile "org.glassfish.jaxb:jaxb-runtime:2.3.2" + compile "jakarta.xml.bind:jakarta.xml.bind-api:2.3.2" + compile "org.glassfish.jaxb:jaxb-runtime:2.3.2" +} + +integrationTest { + useTestNG() + testLogging { + showStandardStreams = true + events "passed", "skipped", "failed" + } +} diff --git a/cerberus-api-tests/src/integration-test/groovy/com/nike/cerberus/api/AdminApiTests.groovy b/cerberus-api-tests/src/integration-test/groovy/com/nike/cerberus/api/AdminApiTests.groovy new file mode 100644 index 000000000..39027843e --- /dev/null +++ b/cerberus-api-tests/src/integration-test/groovy/com/nike/cerberus/api/AdminApiTests.groovy @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.api + +import com.nike.cerberus.util.PropUtils +import com.nike.cerberus.api.util.TestUtils +import org.apache.http.HttpStatus +import org.testng.annotations.BeforeTest +import org.testng.annotations.Test +import org.testng.collections.Maps + +import java.security.NoSuchAlgorithmException + +import static com.nike.cerberus.api.CerberusApiActions.cleanUpOrphanedAndInactiveRecords +import static com.nike.cerberus.api.CerberusApiActions.getSdbMetadata +import static com.nike.cerberus.api.CerberusApiActions.validatePUTApiResponse +import static com.nike.cerberus.api.CerberusCompositeApiActions.* + +class AdminApiTests { + + private String cerberusAuthToken + + @BeforeTest(enabled = false) + void beforeTest() throws NoSuchAlgorithmException { + TestUtils.configureRestAssured() + loadRequiredEnvVars() + } + + private void loadRequiredEnvVars() { + cerberusAuthToken = PropUtils.getRequiredProperty("TEST_ADMIN_CERBERUS_TOKEN", + "A Cerberus auth token with permissions to call admin endpoints") + } + + @Test(enabled = false) + void "test that an admin can call the v1 cleanup endpoint"() { + int deleteKmsKeysAfterNDaysOfInactivity = Integer.MAX_VALUE-1 // try not to actually delete any keys in this test + cleanUpOrphanedAndInactiveRecords(cerberusAuthToken, deleteKmsKeysAfterNDaysOfInactivity) + } + + @Test(enabled = false) + void "test that an admin can call PUT v1 metadata endpoint"() { + String schemaFilePath = "$NEGATIVE_JSON_SCHEMA_ROOT_PATH/put-metadata-category-null.json" + // send invalid data so that actual data is not written, but only the permissions of this call is tested + validatePUTApiResponse(cerberusAuthToken, "v1/metadata", HttpStatus.SC_BAD_REQUEST, schemaFilePath, Maps.newHashMap()) + } + + @Test(enabled = false) + void "test that an admin can call GET v1 metadata endpoint"() { + getSdbMetadata(cerberusAuthToken) + } +} diff --git a/cerberus-api-tests/src/integration-test/groovy/com/nike/cerberus/api/CerberusApiActions.groovy b/cerberus-api-tests/src/integration-test/groovy/com/nike/cerberus/api/CerberusApiActions.groovy new file mode 100644 index 000000000..0685d0eac --- /dev/null +++ b/cerberus-api-tests/src/integration-test/groovy/com/nike/cerberus/api/CerberusApiActions.groovy @@ -0,0 +1,653 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.api + +import com.amazonaws.auth.profile.internal.securitytoken.RoleInfo +import com.amazonaws.auth.profile.internal.securitytoken.STSProfileCredentialsServiceProvider +import com.amazonaws.regions.Regions +import com.amazonaws.services.kms.AWSKMSClient +import com.amazonaws.services.kms.model.DecryptRequest +import com.amazonaws.services.kms.model.DecryptResult +import com.nike.cerberus.util.PropUtils +import com.google.common.cache.Cache +import com.google.common.cache.CacheBuilder +import groovy.json.JsonSlurper +import io.restassured.path.json.JsonPath +import io.restassured.response.Response +import org.apache.commons.lang3.StringUtils +import org.apache.http.HttpStatus + +import java.nio.ByteBuffer +import java.util.concurrent.TimeUnit + +import static io.restassured.RestAssured.* +import static io.restassured.module.jsv.JsonSchemaValidator.* +import static org.hamcrest.Matchers.* +import static org.junit.Assert.* + +import static com.nike.cerberus.api.CerberusCompositeApiActions.* + +class CerberusApiActions { + + public static String V1_SAFE_DEPOSIT_BOX_PATH = "v1/safe-deposit-box" + public static String V2_SAFE_DEPOSIT_BOX_PATH = "v2/safe-deposit-box" + public static String CLEAN_UP_PATH = "/v1/cleanup" + public static String SECRETS_PATH = "/v1/secret" + public static String IAM_ROLE_AUTH_PATH = "/v1/auth/iam-role" + public static String IAM_PRINCIPAL_AUTH_PATH = "/v2/auth/iam-principal" + public static String USER_AUTH_PATH = "v2/auth/user" + public static String USER_TOKEN_REFRESH_PATH = "v2/auth/user/refresh" + public static String SDB_METADATA_PATH = "v1/metadata" + public static String AUTH_TOKEN_HEADER_NAME = "X-Vault-Token" + public static String USER_CREDENTIALS_HEADER_NAME = "Authorization" + public static String SAFE_DEPOSIT_BOX_VERSION_PATHS_PATH = "v1/sdb-secret-version-paths" + public static int SLEEP_IN_MILLISECONDS = PropUtils.getPropWithDefaultValue("SLEEP_IN_MILLISECONDS", + "0").toInteger() + + /** + * Use a cache of KMS clients because creating too many kmsCLients causes a performance bottleneck + */ + private static Cache,AWSKMSClient> kmsClientCache = CacheBuilder.newBuilder() + .expireAfterWrite(30, TimeUnit.MINUTES) + .build(); + + /** + * Executes a delete on the v1 auth endpoint to trigger a logout / destroy token action + * + * @param cerberusAuthToken The token to destroy + */ + static void deleteAuthToken(String cerberusAuthToken) { + given() + .header("X-Vault-Token", cerberusAuthToken) + .when() + .delete("/v1/auth") + .then() + .statusCode(204) + } + + static def retrieveUserAuthToken(username, password, otpSecret, otpDeviceId, retryCount = 0) { + try { + Map authResult = "login user with multi factor authentication (or skip mfa if not required) and return auth data"(username, password, otpSecret, otpDeviceId) + System.out.println("user login successful on try " + retryCount) + authResult + } catch (Throwable t) { + System.err.println("user login failed on try " + retryCount) + if (retryCount < 3) { + sleep(10000) + return retrieveUserAuthToken(username, password, otpSecret, otpDeviceId, retryCount + 1) + } else {throw t} + } + } + + /** + * Authenticates with Cerberus's IAM auth endpoint get token + * + * @param accountId The account id to do iam auth with + * @param roleName The role name to do iam auth with + * @param region The region to do iam auth with + * @return The authentication token + */ + static def retrieveIamAuthToken(String accountId, String roleName, String region, boolean assumeRole = true) { + // get the encrypted payload and validate response + Response response = + given() + .contentType("application/json") + .body([ + 'account_id': accountId, + 'role_name': roleName, + 'region': region + ]) + .when() + .post("/v1/auth/iam-role") + .then() + .statusCode(200) + .contentType("application/json") + .assertThat().body(matchesJsonSchemaInClasspath("json-schema/v1/auth/iam-role-encrypted.json")) + .extract(). + response() + + // decrypt the payload + String base64EncodedKmsEncryptedAuthPayload = response.body().jsonPath().getString("auth_data") + return getDecryptedPayload(String.format("arn:aws:iam::%s:role/%s", accountId, roleName), region, base64EncodedKmsEncryptedAuthPayload, assumeRole) + } + + static def retrieveIamAuthToken(String iamPrincipalArn, String region, boolean assumeRole = true) { + // get the encrypted payload and validate response + Response response = + given() + .contentType("application/json") + .body([ + 'iam_principal_arn': iamPrincipalArn, + 'region': region + ]) + .when() + .post("/v2/auth/iam-principal") + .then() + .statusCode(200) + .contentType("application/json") + .assertThat().body(matchesJsonSchemaInClasspath("json-schema/v2/auth/iam-role-encrypted.json")) + .extract(). + response() + + // decrypt the payload + String base64EncodedKmsEncryptedAuthPayload = response.body().jsonPath().getString("auth_data") + return getDecryptedPayload(iamPrincipalArn, region, base64EncodedKmsEncryptedAuthPayload, assumeRole) + } + + static def getDecryptedPayload(String iamPrincipalArn, String region, String base64EncodedKmsEncryptedAuthPayload, boolean assumeRole = true) { + AWSKMSClient kmsClient = kmsClientCache.getIfPresent(new Tuple2(iamPrincipalArn, region)) + + if(kmsClient == null) { + System.out.println("getDecryptedPayload() kmsClient cache miss, creating kmsClient for " + iamPrincipalArn + " " + region) + if (assumeRole) { + kmsClient = new AWSKMSClient(new STSProfileCredentialsServiceProvider( + new RoleInfo().withRoleArn(iamPrincipalArn) + .withRoleSessionName(UUID.randomUUID().toString()))).withRegion(Regions.fromName(region)) + } else { + kmsClient = new AWSKMSClient().withRegion(Regions.fromName(region)) + } + + kmsClientCache.put(new Tuple2(iamPrincipalArn, region), kmsClient) + } + + DecryptResult result = kmsClient.decrypt( + new DecryptRequest() + .withCiphertextBlob( + ByteBuffer.wrap(Base64.getDecoder().decode(base64EncodedKmsEncryptedAuthPayload))) + ) + + // validate decrypted schema and return auth token + String jsonString = new String(result.getPlaintext().array()) + assertThat(jsonString, matchesJsonSchemaInClasspath("json-schema/v2/auth/iam-role-decrypted.json")) + return new JsonSlurper().parseText(jsonString) + } + + static String decryptAuthTokenAsRoleAndRetrieveToken(String iamPrincipalArn, String region, String base64EncodedKmsEncryptedAuthPayload) { + return getDecryptedPayload(iamPrincipalArn, region, base64EncodedKmsEncryptedAuthPayload, true)."client_token" + } + + static void createOrUpdateSecretNode(Map data, String path, String cerberusAuthToken) { + given() + .header("X-Vault-Token", cerberusAuthToken) + .body(data) + .when() + .post("/v1/secret/${path}") + .then() + .statusCode(204) + } + + static void createOrUpdateFile(byte[] file, String path, String cerberusAuthToken) { + String filename = StringUtils.substringAfterLast(path, "/") + given() + .header("X-Cerberus-Token", cerberusAuthToken) + .multiPart('file-content', filename, file) + .when() + .post("/v1/secure-file/${path}") + .then() + .statusCode(204) + } + + static JsonPath readSecretNode(String path, String cerberusAuthToken) { + sleep(SLEEP_IN_MILLISECONDS) + given() + .header("X-Vault-Token", cerberusAuthToken) + .when() + .get("/v1/secret/${path}") + .then() + .statusCode(200) + .contentType("application/json") + .assertThat().body(matchesJsonSchemaInClasspath("json-schema/v1/secret/get-secret.json")) + .extract() + .body().jsonPath() + } + + static byte[] readSecureFile(String path, String cerberusAuthToken, byte[] expectedFileBytes, String versionId=null) { + sleep(SLEEP_IN_MILLISECONDS) + String uri = versionId ? "/v1/secure-file/${path}?versionId=${versionId}" : "/v1/secure-file/${path}" + given() + .header("X-Cerberus-Token", cerberusAuthToken) + .when() + .get(uri) + .then() + .statusCode(200) + .assertThat().body(equalTo(new String(expectedFileBytes))) + .extract() + .body().asByteArray() + } + + static JsonPath listSecureFileSummaries(String path, String cerberusAuthToken) { + given() + .header("X-Cerberus-Token", cerberusAuthToken) + .when() + .get("/v1/secure-files/${path}") + .then() + .statusCode(200) + .assertThat().body(matchesJsonSchemaInClasspath("json-schema/v1/secure-file/list-summaries.json")) + .extract() + .body().jsonPath() + } + + static JsonPath getSecretNodeVersionsMetadata(String path, String cerberusAuthToken) { + given() + .header("X-Vault-Token", cerberusAuthToken) + .when() + .get("/v1/secret-versions/${path}") + .then() + .statusCode(200) + .contentType("application/json") + .assertThat().body(matchesJsonSchemaInClasspath("json-schema/v1/secret/get-secret-versions-metadata.json")) + .extract() + .body().jsonPath() + } + + static JsonPath getSdbVersionPaths(String sdbId, String cerberusAuthToken, String baseSdbVersionPathsPath = SAFE_DEPOSIT_BOX_VERSION_PATHS_PATH) { + given() + .header("X-Vault-Token", cerberusAuthToken) + .when() + .get("${baseSdbVersionPathsPath}/${sdbId}") + .then() + .statusCode(200) + .contentType("application/json") + .extract() + .body().jsonPath() + } + + static JsonPath readSecretNodeVersion(String path, String versionId, String cerberusAuthToken) { + given() + .header("X-Vault-Token", cerberusAuthToken) + .when() + .get("/v1/secret/${path}?versionId=${versionId}") + .then() + .statusCode(200) + .contentType("application/json") + .assertThat().body(matchesJsonSchemaInClasspath("json-schema/v1/secret/get-secret-version.json")) + .extract() + .body().jsonPath() + } + + static void deleteSecretNode(String path, String cerberusAuthToken) { + given() + .header("X-Vault-Token", cerberusAuthToken) + .when() + .delete("/v1/secret/${path}") + .then() + .statusCode(204) + } + + static void deleteSecureFile(String path, String cerberusAuthToken) { + given() + .header("X-Cerberus-Token", cerberusAuthToken) + .when() + .delete("/v1/secure-file/${path}") + .then() + .statusCode(204) + } + + static void assertThatSecretNodeDoesNotExist(String path, String cerberusAuthToken) { + given() + .header("X-Vault-Token", cerberusAuthToken) + .when() + .get("/v1/secret/${path}") + .then() + .statusCode(404) + } + + static void assertThatSecureFileDoesNotExist(String path, String cerberusAuthToken) { + given() + .header("X-Cerberus-Token", cerberusAuthToken) + .when() + .get("/v1/secure-file/${path}") + .then() + .statusCode(404) + } + + static JsonPath loginUser(String username, String password) { + def body = + given() + .header("Authorization", "Basic ${"$username:$password".bytes.encodeBase64()}") + .when() + .get("/v2/auth/user") + .then() + .statusCode(200) + .contentType("application/json") + .extract() + .body() + + String status = body.jsonPath().getString("status") + if (status == "success") { + assertThat(body.asString(), matchesJsonSchemaInClasspath("json-schema/v2/auth/user-success.json")) + } else if (status == "mfa_req") { + assertThat(body.asString(), matchesJsonSchemaInClasspath("json-schema/v2/auth/user-mfa_req.json")) + } else { + throw new IllegalStateException("unreconized status from login user: ${status}") + } + return body.jsonPath() + } + + static void logoutUser(String cerberusAuthToken) { + deleteAuthToken(cerberusAuthToken) + } + + static JsonPath finishMfaUserAuth(String stateToken, String deviceId, String otpToken) { + given() + .contentType("application/json") + .body([ + 'state_token': stateToken, + 'device_id': deviceId, + 'otp_token': otpToken + ]) + .when() + .post("/v2/auth/mfa_check") + .then() + .statusCode(200) + .contentType("application/json") + .assertThat().body(matchesJsonSchemaInClasspath("json-schema/v2/auth/mfa_check.json")) + .extract(). + body().jsonPath() + } + + static String createSdbV1(String cerberusAuthToken, + String name, + String description, + String categoryId, + String owner, + List> userGroupPermissions, + List> iamRolePermissions) { + + given() + .header("X-Vault-Token", cerberusAuthToken) + .contentType("application/json") + .body([ + name: name, + description: description, + 'category_id': categoryId, + owner: owner, + 'user_group_permissions': userGroupPermissions, + 'iam_role_permissions': iamRolePermissions + ]) + .when() + .post(V1_SAFE_DEPOSIT_BOX_PATH) + .then() + .statusCode(201) + .header('X-Refresh-Token', 'false') + .header('Location', not(isEmptyOrNullString())) + .assertThat().body(matchesJsonSchemaInClasspath("json-schema/$V1_SAFE_DEPOSIT_BOX_PATH/create_success.json")) + .extract(). + body().jsonPath().getString("id") + } + + static JsonPath createSdbV2(String cerberusAuthToken, + String name, + String description, + String categoryId, + String owner, + List> userGroupPermissions, + List> iamPrincipalPermissions) { + + given() + .header("X-Vault-Token", cerberusAuthToken) + .contentType("application/json") + .body([ + name: name, + description: description, + 'category_id': categoryId, + owner: owner, + 'user_group_permissions': userGroupPermissions, + 'iam_principal_permissions': iamPrincipalPermissions + ]) + .when() + .post(V2_SAFE_DEPOSIT_BOX_PATH) + .then() + .statusCode(201) + .header('X-Refresh-Token', 'false') + .header('Location', not(isEmptyOrNullString())) + .assertThat().body(matchesJsonSchemaInClasspath("json-schema/$V2_SAFE_DEPOSIT_BOX_PATH/create_success.json")) + .extract(). + body().jsonPath() + } + + static JsonPath readSdb(String cerberusAuthToken, String sdbId, String baseSdbApiPath = V1_SAFE_DEPOSIT_BOX_PATH) { + given() + .header("X-Vault-Token", cerberusAuthToken) + .when() + .get("${baseSdbApiPath}/${sdbId}") + .then() + .statusCode(200) + .assertThat().body(matchesJsonSchemaInClasspath("json-schema/$baseSdbApiPath/read_success.json")) + .extract(). + body().jsonPath() + } + + static void updateSdbV1(String cerberusAuthToken, + String sdbId, + String description, + String owner, + List> userGroupPermissions, + List> iamRolePermissions) { + + given() + .header("X-Vault-Token", cerberusAuthToken) + .contentType("application/json") + .body([ + description: description, + owner: owner, + 'user_group_permissions': userGroupPermissions, + 'iam_role_permissions': iamRolePermissions + ]) + .when() + .put("$V1_SAFE_DEPOSIT_BOX_PATH/${sdbId}") + .then() + .statusCode(204) + .header('X-Refresh-Token', 'false') + + } + + static JsonPath updateSdbV2(String cerberusAuthToken, + String sdbId, + String description, + String owner, + List> userGroupPermissions, + List> iamPrincipalPermissions) { + + given() + .header("X-Vault-Token", cerberusAuthToken) + .contentType("application/json") + .body([ + description: description, + owner: owner, + 'user_group_permissions': userGroupPermissions, + 'iam_principal_permissions': iamPrincipalPermissions + ]) + .when() + .put("$V2_SAFE_DEPOSIT_BOX_PATH/${sdbId}") + .then() + .statusCode(200) + .header('X-Refresh-Token', 'false') + .assertThat().body(matchesJsonSchemaInClasspath("json-schema/$V2_SAFE_DEPOSIT_BOX_PATH/read_success.json")) + .extract(). + body().jsonPath() + } + + static void deleteSdb(String cerberusAuthToken, String sdbId, String baseSdbApiPath = V1_SAFE_DEPOSIT_BOX_PATH) { + given() + .header("X-Vault-Token", cerberusAuthToken) + .when() + .delete("${baseSdbApiPath}/${sdbId}") + .then() + .statusCode(204) + .header('X-Refresh-Token', 'false') + } + + static JsonPath getRoles(String cerberusAuthToken) { + given() + .header("X-Vault-Token", cerberusAuthToken) + .when() + .get("/v1/role") + .then() + .statusCode(200) + .assertThat().body(matchesJsonSchemaInClasspath("json-schema/v1/role/get_roles_success.json")) + .extract(). + body().jsonPath() + } + + static JsonPath getCategories(String cerberusAuthToken) { + given() + .header("X-Vault-Token", cerberusAuthToken) + .when() + .get("/v1/category") + .then() + .statusCode(200) + .assertThat().body(matchesJsonSchemaInClasspath("json-schema/v1/category/get_categories_success.json")) + .extract(). + body().jsonPath() + } + + static JsonPath listSdbs(String cerberusAuthToken, String baseSdbApiPath = V1_SAFE_DEPOSIT_BOX_PATH) { + sleep(SLEEP_IN_MILLISECONDS) + given() + .header("X-Vault-Token", cerberusAuthToken) + .when() + .get(baseSdbApiPath) + .then() + .statusCode(200) + .assertThat().body(matchesJsonSchemaInClasspath("json-schema/v1/safe-deposit-box/list_success.json")) + .extract(). + body().jsonPath() + } + + static String getSdbIdByPath(String pathToSearch, String cerberusAuthToken) { + def sdbList = listSdbs(cerberusAuthToken).get() + for(sdb in sdbList){ + def path = sdb.get('path') + // When the SDB path contains trailing slash + if(path.endsWith('/')){ + path = path.take(path.length() - 1) + } + if(pathToSearch == path) { + return sdb.get('id') + } + } + return null + } + + static void cleanUpOrphanedAndInactiveRecords(String cerberusAuthToken, Integer expirationPeriodInDays = null) { + given() + .header("X-Vault-Token", cerberusAuthToken) + .body([ + kms_expiration_period_in_days: expirationPeriodInDays + ]) + .when() + .put(CLEAN_UP_PATH) + .then() + .statusCode(HttpStatus.SC_NO_CONTENT) + } + + static void getSdbMetadata(String cerberusAuthToken) { + given() + .header("X-Vault-Token", cerberusAuthToken) + .when() + .get(SDB_METADATA_PATH) + .then() + .statusCode(HttpStatus.SC_OK) + } + + static void loadDashboardIndexHtml(String partialUriPath) { + given().when() + .get(partialUriPath) + .then() + .statusCode(HttpStatus.SC_OK) + .body(stringContainsInOrder(["", "Cerberus", ""])) + } + + + static refreshUserAuthToken(String cerberusAuthToken) { + given() + .header("X-Vault-Token", cerberusAuthToken) + .when() + .get(USER_TOKEN_REFRESH_PATH) + .then() + .statusCode(200) + .extract(). + body().jsonPath() + } + + static void validateGETApiResponse(String headerName, String headerValue, String requestPath, int statusCode, String pathToJsonSchemaFile) { + given() + .header(headerName, headerValue) + .when() + .get(requestPath) + .then() + .statusCode(statusCode) + .assertThat().body(matchesJsonSchemaInClasspath(pathToJsonSchemaFile)) + } + + static void validatePUTApiResponse(String cerberusAuthToken, String requestPath, int statusCode, String pathToJsonSchemaFile, Map body) { + given() + .header("X-Vault-Token", cerberusAuthToken) + .header("Content-Type", "application/json") + .body(body) + .when() + .put(requestPath) + .then() + .statusCode(statusCode) + .assertThat().body(matchesJsonSchemaInClasspath(pathToJsonSchemaFile)) + } + + static void validatePOSTApiResponse(String cerberusAuthToken, String requestPath, int statusCode, String pathToJsonSchemaFile, Map body) { + given() + .header("X-Vault-Token", cerberusAuthToken) + .header("Content-Type", "application/json") + .body(body) + .when() + .post(requestPath) + .then() + .statusCode(statusCode) + .assertThat().body(matchesJsonSchemaInClasspath(pathToJsonSchemaFile)) + } + + static void validateDELETEApiResponse(String cerberusAuthToken, String requestPath, int statusCode, String pathToJsonSchemaFile) { + given() + .header("X-Vault-Token", cerberusAuthToken) + .when() + .delete(requestPath) + .then() + .statusCode(statusCode) + .assertThat().body(matchesJsonSchemaInClasspath(pathToJsonSchemaFile)) + } + + static Map getRoleMap(String cerberusAuthToken) { + // Create a map of role ids to names + JsonPath getRolesResponse = getRoles(cerberusAuthToken) + def roleMap = [:] + getRolesResponse.getList("").each { role -> + roleMap.put role.name, role.id + } + + return roleMap + } + + static Map getCategoryMap(String cerberusAuthToken) { + // Create a map of category ids to names' + JsonPath getCategoriesResponse = getCategories(cerberusAuthToken) + def catMap = [:] + getCategoriesResponse.getList("").each { category -> + catMap.put category.display_name, category.id + } + + return catMap + } +} diff --git a/cerberus-api-tests/src/integration-test/groovy/com/nike/cerberus/api/CerberusCompositeApiActions.groovy b/cerberus-api-tests/src/integration-test/groovy/com/nike/cerberus/api/CerberusCompositeApiActions.groovy new file mode 100644 index 000000000..7fab86171 --- /dev/null +++ b/cerberus-api-tests/src/integration-test/groovy/com/nike/cerberus/api/CerberusCompositeApiActions.groovy @@ -0,0 +1,402 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.api + +import com.amazonaws.util.IOUtils +import com.nike.cerberus.util.PropUtils +import com.thedeanda.lorem.Lorem +import io.restassured.path.json.JsonPath +import org.apache.commons.lang3.RandomStringUtils +import org.jboss.aerogear.security.otp.Totp + +import static org.junit.Assert.assertEquals +import static com.nike.cerberus.api.CerberusApiActions.* +import static org.junit.Assert.assertFalse +import static org.junit.Assert.assertNotNull +import static org.junit.Assert.assertTrue + +class CerberusCompositeApiActions { + private CerberusCompositeApiActions() {} + + static final String ROOT_INTEGRATION_TEST_SDB_PATH = "app/cerberus-integration-tests-sdb" + static final String PRE_EXISTING_TEST_SECRET_PATH = "${ROOT_INTEGRATION_TEST_SDB_PATH}/default-test-secret" + static final String NEGATIVE_JSON_SCHEMA_ROOT_PATH = "json-schema/negative" + + static void "create, read, update then delete a secret node"(String cerberusAuthToken) { + "create, read, update then delete a secret node"(cerberusAuthToken, ROOT_INTEGRATION_TEST_SDB_PATH) + } + + static void "create, read, update then delete a secret node"(String cerberusAuthToken, String sdbPath) { + def path = "${sdbPath}/${UUID.randomUUID().toString()}" + String value1 = 'value1' + String value2 = 'value2' + + // Create the initial secret node + createOrUpdateSecretNode([value: value1], path, cerberusAuthToken) + // Read and verify that it was created + def resp = readSecretNode(path, cerberusAuthToken) + assertEquals(value1, resp?.'data'?.'value') + // Update the secret node + createOrUpdateSecretNode([value: value2], path, cerberusAuthToken) + // Read that the node was updated + def resp2 = readSecretNode(path, cerberusAuthToken) + assertEquals(value2, resp2?.'data'?.'value') + // Delete the node + deleteSecretNode(path, cerberusAuthToken) + // Verify that the node was deleted + assertThatSecretNodeDoesNotExist(path, cerberusAuthToken) + } + + static void "create, read, update then delete a file"(String cerberusAuthToken) { + "create, read, update then delete a file"(cerberusAuthToken, ROOT_INTEGRATION_TEST_SDB_PATH) + } + + static void "create, read, update then delete a file"(String cerberusAuthToken, String sdbPath) { + InputStream originalFile = Thread.currentThread().getContextClassLoader().getResourceAsStream("example-file.pem") + byte[] originalFileBytes = IOUtils.toByteArray(originalFile); + + InputStream updatedFile = Thread.currentThread().getContextClassLoader().getResourceAsStream("updated-file.pem") + byte[] updatedFileBytes = IOUtils.toByteArray(updatedFile); + def path = "${sdbPath}/${UUID.randomUUID().toString().substring(0, 8) + ".pem"}" + + // Create the initial secret node + createOrUpdateFile(originalFileBytes, path, cerberusAuthToken) + // Read and verify that it was created + readSecureFile(path, cerberusAuthToken, originalFileBytes) + // Update the secret node + createOrUpdateFile(updatedFileBytes, path, cerberusAuthToken) + // Read that the node was updated + readSecureFile(path, cerberusAuthToken, updatedFileBytes) + // List files in SDB + listSecureFileSummaries("${ROOT_INTEGRATION_TEST_SDB_PATH}/", cerberusAuthToken) + // List file versions + def fileVersionsResponse = getSecretNodeVersionsMetadata(path, cerberusAuthToken) + assertEquals(fileVersionsResponse?.'total_version_count', 2) + // Read original file using version ID + def originalFileVersionId = fileVersionsResponse?.secure_data_version_summaries[1]?.'id' + readSecureFile(path, cerberusAuthToken, originalFileBytes, originalFileVersionId) + // Delete the node + deleteSecureFile(path, cerberusAuthToken) + // Verify that the node was deleted + assertThatSecureFileDoesNotExist(path, cerberusAuthToken) + + originalFile.close() + updatedFile.close() + } + + static void "read secret node versions"(String cerberusAuthToken) { + def path = "${ROOT_INTEGRATION_TEST_SDB_PATH}/${UUID.randomUUID().toString()}" + String value1 = 'value1' + String value2 = 'value2' + + // Create the initial secret node + createOrUpdateSecretNode([value: value1], path, cerberusAuthToken) + // Update the secret node + createOrUpdateSecretNode([value: value2], path, cerberusAuthToken) + + // The versions are sorted by timestamp. Automated test executes very quickly, causing the 'update' and + // 'delete' timestamp to be the same which causes an issue with sorting. Sleep is used to ensure correct order. + sleep(1000) + // Delete the node + deleteSecretNode(path, cerberusAuthToken) + // Verify that the node was deleted + assertThatSecretNodeDoesNotExist(path, cerberusAuthToken) + + def metadataResp = getSecretNodeVersionsMetadata(path, cerberusAuthToken) + assertEquals(metadataResp?.'total_version_count', 2) + // Read the oldest version + def versionId1 = metadataResp?.'secure_data_version_summaries'[1]?.'id' + def secretResp1 = readSecretNodeVersion(path, versionId1, cerberusAuthToken) + assertEquals(value1, secretResp1?.'data'?.'value') + // Read the second oldest version + def versionId2 = metadataResp?.'secure_data_version_summaries'[0]?.'id' + def secretResp2 = readSecretNodeVersion(path, versionId2, cerberusAuthToken) + assertEquals(value2, secretResp2?.'data'?.'value') + def sdbId = getSdbIdByPath(ROOT_INTEGRATION_TEST_SDB_PATH, cerberusAuthToken) + assertNotNull(sdbId) + // Verify that this secret node is in the list of changed secret nodes + def sdbVersions = getSdbVersionPaths(sdbId, cerberusAuthToken) + def list = sdbVersions.get() + assertTrue(list.contains(path.toString())) + } + + static void "v1 create, read, list, update and then delete a safe deposit box"(Map cerberusAuthPayloadData) { + String accountId = PropUtils.getPropWithDefaultValue("TEST_ACCOUNT_ID", "1111111111") + String roleName = PropUtils.getPropWithDefaultValue("TEST_ROLE_NAME", "fake_role") + String cerberusAuthToken = cerberusAuthPayloadData.'client_token' + String groups = cerberusAuthPayloadData.metadata.groups + def group = groups.split(/,/)[0] + + // Create a map of category ids to names' + JsonPath getCategoriesResponse = getCategories(cerberusAuthToken) + def catMap = [:] + getCategoriesResponse.getList("").each { category -> + catMap.put category.display_name, category.id + } + // Create a map of role ids to names + JsonPath getRolesResponse = getRoles(cerberusAuthToken) + def roleMap = [:] + getRolesResponse.getList("").each { role -> + roleMap.put role.name, role.id + } + + String name = "${RandomStringUtils.randomAlphabetic(5,10)} ${RandomStringUtils.randomAlphabetic(5,10)}" + String description = "${Lorem.getWords(50)}" + String categoryId = catMap.Applications + String owner = group + def userGroupPermissions = [ + [ + "name": 'foo', + "role_id": roleMap.read + ] + ] + def iamRolePermissions = [ + [ + "account_id": accountId, + "iam_role_name": roleName, + "role_id": roleMap.owner + ] + ] + + def sdbId = createSdbV1(cerberusAuthToken, name, description, categoryId, owner, userGroupPermissions, iamRolePermissions) + JsonPath sdb = readSdb(cerberusAuthToken, sdbId, V1_SAFE_DEPOSIT_BOX_PATH) + + // verify that the sdb we created contains the data we expect + assertSafeDepositBoxV1HasFields(sdb, name, description, categoryId, owner, userGroupPermissions, iamRolePermissions) + + // verify that the listing call contains our new SDB + def sdbList = listSdbs(cerberusAuthToken, V1_SAFE_DEPOSIT_BOX_PATH) + def foundNewSdb = false + def listSdb + + sdbList.getList("").each { sdbMeta -> + if (sdbMeta.id == sdbId) { + foundNewSdb = true + listSdb = sdbMeta + } + } + assertTrue("Failed to find the newly created SDB in the list results", foundNewSdb) + assertEquals(listSdb.name, sdb.get('name')) + assertEquals(listSdb.id, sdb.get('id')) + assertEquals(listSdb.path, sdb.get('path')) + assertEquals(listSdb.'category_id', sdb.get('category_id')) + + // update the sdb + description = "${Lorem.getWords(60)}" + userGroupPermissions.add([ + "name": 'bar', + "role_id": roleMap.write + ]) + iamRolePermissions.add([ + "account_id": "1111111111", + "iam_role_name": "fake_role2", + "role_id": roleMap.read + ]) + updateSdbV1(cerberusAuthToken, sdbId, description, owner, userGroupPermissions, iamRolePermissions) + JsonPath sdbUpdated = readSdb(cerberusAuthToken, sdbId, V1_SAFE_DEPOSIT_BOX_PATH) + + // verify that the sdbUpdated we created contains the data we expect + assertSafeDepositBoxV1HasFields(sdbUpdated, name, description, categoryId, owner, userGroupPermissions, iamRolePermissions) + + // delete the SDB + deleteSdb(cerberusAuthToken, sdbId, V1_SAFE_DEPOSIT_BOX_PATH) + + // verify that the sdb is not longer in the list + def updatedSdbList = listSdbs(cerberusAuthToken, V1_SAFE_DEPOSIT_BOX_PATH) + def isSdbPresentInUpdatedList = false + + updatedSdbList.getList("").each { sdbMeta -> + if (sdbMeta.id == sdbId) { + isSdbPresentInUpdatedList = true + } + } + assertFalse("The created sdb should not be in the sdb listing call after deleting it", isSdbPresentInUpdatedList) + } + + static void "v2 create, read, list, update and then delete a safe deposit box"(Map cerberusAuthPayloadData) { + String accountId = PropUtils.getPropWithDefaultValue("TEST_ACCOUNT_ID", "1111111111") + String roleName = PropUtils.getPropWithDefaultValue("TEST_ROLE_NAME", "fake_role") + String cerberusAuthToken = cerberusAuthPayloadData.'client_token' + String groups = cerberusAuthPayloadData.metadata.groups + def group = groups.split(/,/)[0] + + // Create a map of category ids to names' + JsonPath getCategoriesResponse = getCategories(cerberusAuthToken) + def catMap = [:] + getCategoriesResponse.getList("").each { category -> + catMap.put category.display_name, category.id + } + // Create a map of role ids to names + JsonPath getRolesResponse = getRoles(cerberusAuthToken) + def roleMap = [:] + getRolesResponse.getList("").each { role -> + roleMap.put role.name, role.id + } + + String name = "${RandomStringUtils.randomAlphabetic(5,10)} ${RandomStringUtils.randomAlphabetic(5,10)}" + String description = "${Lorem.getWords(50)}" + String categoryId = catMap.Applications + String owner = group + def userGroupPermissions = [ + [ + "name": 'foo', + "role_id": roleMap.read + ] + ] + + String arn = "arn:aws:iam::${accountId}:role/${roleName}" + def iamPrincipalPermissions = [ + [ + "iam_principal_arn": arn, + "role_id": roleMap.owner + ] + ] + + // verify that the sdb we created contains the data we expect + def createdSdb = createSdbV2(cerberusAuthToken, name, description, categoryId, owner, userGroupPermissions, iamPrincipalPermissions) + assertSafeDepositBoxV2HasFields(createdSdb, name, description, categoryId, owner, userGroupPermissions, iamPrincipalPermissions) + + // test read sdb returns returns expected data + def sdbId = createdSdb.getString("id") + JsonPath sdb = readSdb(cerberusAuthToken, sdbId, V2_SAFE_DEPOSIT_BOX_PATH) + assertSafeDepositBoxV2HasFields(sdb, name, description, categoryId, owner, userGroupPermissions, iamPrincipalPermissions) + + // verify that the listing call contains our new SDB + def sdbList = listSdbs(cerberusAuthToken, V2_SAFE_DEPOSIT_BOX_PATH) + def foundNewSdb = false + def listSdb + + sdbList.getList("").each { sdbMeta -> + if (sdbMeta.id == sdbId) { + foundNewSdb = true + listSdb = sdbMeta + } + } + assertTrue("Failed to find the newly created SDB in the list results", foundNewSdb) + assertEquals(listSdb.name, sdb.get('name')) + assertEquals(listSdb.id, sdb.get('id')) + assertEquals(listSdb.path, sdb.get('path')) + assertEquals(listSdb.'category_id', sdb.get('category_id')) + + // update the sdb + description = "${Lorem.getWords(60)}" + userGroupPermissions.add([ + "name": 'bar', + "role_id": roleMap.write + ]) + iamPrincipalPermissions.add([ + "iam_principal_arn": "arn:aws:iam::1111111111:role/fake_role2", + "role_id": roleMap.read + ]) + JsonPath sdbUpdatedUpdate = updateSdbV2(cerberusAuthToken, sdbId, description, owner, userGroupPermissions, iamPrincipalPermissions) + + // verify that the sdbUpdated we created contains the data we expect + assertSafeDepositBoxV2HasFields(sdbUpdatedUpdate, name, description, categoryId, owner, userGroupPermissions, iamPrincipalPermissions) + + JsonPath sdbUpdatedRead = readSdb(cerberusAuthToken, sdbId, V2_SAFE_DEPOSIT_BOX_PATH) + assertSafeDepositBoxV2HasFields(sdbUpdatedRead, name, description, categoryId, owner, userGroupPermissions, iamPrincipalPermissions) + + // delete the SDB + deleteSdb(cerberusAuthToken, sdbId, V2_SAFE_DEPOSIT_BOX_PATH) + + // verify that the sdb is not longer in the list + def updatedSdbList = listSdbs(cerberusAuthToken, V2_SAFE_DEPOSIT_BOX_PATH) + def isSdbPresentInUpdatedList = false + + updatedSdbList.getList("").each { sdbMeta -> + if (sdbMeta.id == sdbId) { + isSdbPresentInUpdatedList = true + } + } + assertFalse("The created sdb should not be in the sdb listing call after deleting it", isSdbPresentInUpdatedList) + } + + static Map "login user with multi factor authentication (or skip mfa if not required) and return auth data"( + String username, String password, String otpSecret, String deviceId) { + + JsonPath loginResp = loginUser(username, password) + String status = loginResp.getString("status") + if (status == "success") { + return loginResp.get("data.client_token") + } else { + def mfaResp = finishMfaUserAuth( + loginResp.getString("data.state_token"), + deviceId, + new Totp(otpSecret).now()) + + return mfaResp.get('data.client_token') + } + } + + private static void assertIamPermissionsEquals(boolean isV1, def expectedIamPermissions, def actualIamPermissions) { + assertEquals(expectedIamPermissions.size(), actualIamPermissions.size()) + for (def expectedPerm : expectedIamPermissions) { + def found = false + for (def actualPerm : actualIamPermissions) { + if (isV1) { + if (expectedPerm.'iam_role_name' == actualPerm.'iam_role_name') { + found = true + assertEquals(expectedPerm.'account_id', actualPerm.'account_id') + } + } else { + if (expectedPerm.'iam_principal_arn' == actualPerm.'iam_principal_arn') { + found = true + } + } + } + assertTrue("The expected user permission was not found in the actual results", found) + } + } + + private static void assertUserGroupPermissionsEquals(def expectedUserGroupPermissions, def actualUserGroupPermissions) { + + assertEquals(expectedUserGroupPermissions.size(), actualUserGroupPermissions.size()) + for (def expectedPerm : expectedUserGroupPermissions) { + def found = false + for (def actualPerm : actualUserGroupPermissions) { + if (expectedPerm.name == actualPerm.name) { + found = true + assertEquals(expectedPerm.'role_id', actualPerm.'role_id') + assertEquals(expectedPerm.name, actualPerm.name) + } + } + assertTrue("The expected user permission was not found in the actual results", found) + } + } + + private static void assertSafeDepositBoxV1HasFields(def safeDepositBox, def name, def description, def categoryId, + def owner, def userGroupPermissions, def iamRolePermissions) { + + assertEquals(name, safeDepositBox.get('name')) + assertEquals(description, safeDepositBox.get('description')) + assertEquals(categoryId, safeDepositBox.get('category_id')) + assertEquals(owner, safeDepositBox.get('owner')) + assertIamPermissionsEquals(true, iamRolePermissions, safeDepositBox.getList('iam_role_permissions')) + assertUserGroupPermissionsEquals(userGroupPermissions, safeDepositBox.getList('user_group_permissions')) + } + + private static void assertSafeDepositBoxV2HasFields(def safeDepositBox, def name, def description, def categoryId, + def owner, def userGroupPermissions, def iamPrincipalPermissions) { + + assertEquals(name, safeDepositBox.get('name')) + assertEquals(description, safeDepositBox.get('description')) + assertEquals(categoryId, safeDepositBox.get('category_id')) + assertEquals(owner, safeDepositBox.get('owner')) + assertIamPermissionsEquals(false, iamPrincipalPermissions, safeDepositBox.getList('iam_principal_permissions')) + assertUserGroupPermissionsEquals(userGroupPermissions, safeDepositBox.getList('user_group_permissions')) + } +} diff --git a/cerberus-api-tests/src/integration-test/groovy/com/nike/cerberus/api/CerberusDashboardApiTests.groovy b/cerberus-api-tests/src/integration-test/groovy/com/nike/cerberus/api/CerberusDashboardApiTests.groovy new file mode 100644 index 000000000..910dc51c2 --- /dev/null +++ b/cerberus-api-tests/src/integration-test/groovy/com/nike/cerberus/api/CerberusDashboardApiTests.groovy @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.api + +import org.testng.annotations.Test + +import static com.nike.cerberus.api.CerberusApiActions.* + +class CerberusDashboardApiTests { + + @Test + void "test an unauthenticated user can load the dashboard page"() { + def dashboardIndexHtmlURIs = ["/dashboard", "/dashboard/", "/", "/dashboard/index.html"] + + // make sure that all of the redirect and direct URLs work for the dashboard + dashboardIndexHtmlURIs.each { loadDashboardIndexHtml(it) } + } +} diff --git a/cerberus-api-tests/src/integration-test/groovy/com/nike/cerberus/api/CerberusIamApiTests.groovy b/cerberus-api-tests/src/integration-test/groovy/com/nike/cerberus/api/CerberusIamApiTests.groovy new file mode 100644 index 000000000..2d1c03117 --- /dev/null +++ b/cerberus-api-tests/src/integration-test/groovy/com/nike/cerberus/api/CerberusIamApiTests.groovy @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.api + +import com.fasterxml.jackson.databind.ObjectMapper +import com.nike.cerberus.util.PropUtils +import com.nike.cerberus.api.util.TestUtils +import org.testng.annotations.AfterTest +import org.testng.annotations.BeforeTest +import org.testng.annotations.Test + +import java.security.NoSuchAlgorithmException + +import static com.nike.cerberus.api.CerberusCompositeApiActions.* +import static com.nike.cerberus.api.CerberusApiActions.* + +class CerberusIamApiTests { + + private String accountId + private String roleName + private String region + private String cerberusAuthToken + private def cerberusAuthData + + private ObjectMapper mapper + + @BeforeTest + void beforeTest() throws NoSuchAlgorithmException { + mapper = new ObjectMapper() + TestUtils.configureRestAssured() + loadRequiredEnvVars() + cerberusAuthData = retrieveIamAuthToken(accountId, roleName, region) + cerberusAuthToken = cerberusAuthData."client_token" + } + + @AfterTest + void afterTest() { + deleteAuthToken(cerberusAuthToken) + } + + private void loadRequiredEnvVars() { + accountId = PropUtils.getRequiredProperty("TEST_ACCOUNT_ID", + "The account id to use when authenticating with Cerberus using the IAM Auth endpoint") + + roleName = PropUtils.getRequiredProperty("TEST_ROLE_NAME", + "The role name to use when authenticating with Cerberus using the IAM Auth endpoint") + + region = PropUtils.getRequiredProperty("TEST_REGION", + "The region to use when authenticating with Cerberus using the IAM Auth endpoint") + } + + @Test + void "test that an authenticated IAM role can create, read, update then delete a secret node"() { + 'create, read, update then delete a secret node'(cerberusAuthToken) + } + + @Test + void "test that an authenticated IAM role can create, read, update, then delete a file"() { + "create, read, update then delete a file"(cerberusAuthToken) + } + + @Test + void "test that an authenticated IAM role can read secret node versions"() { + 'read secret node versions'(cerberusAuthToken) + } + + @Test + void "test that an authenticated IAM role can create, read, update then delete a safe deposit box v1"() { + "v1 create, read, list, update and then delete a safe deposit box"(cerberusAuthData) + } + + @Test + void "test that an authenticated IAM role can create, read, update then delete a safe deposit box v2"() { + "v2 create, read, list, update and then delete a safe deposit box"(cerberusAuthData) + } + + @Test + void "test that an authenticated IAM role can read a preexisting secret"() { + readSecretNode(PRE_EXISTING_TEST_SECRET_PATH, cerberusAuthToken) + } +} diff --git a/cerberus-api-tests/src/integration-test/groovy/com/nike/cerberus/api/CerberusIamApiV2Tests.groovy b/cerberus-api-tests/src/integration-test/groovy/com/nike/cerberus/api/CerberusIamApiV2Tests.groovy new file mode 100644 index 000000000..54c878d47 --- /dev/null +++ b/cerberus-api-tests/src/integration-test/groovy/com/nike/cerberus/api/CerberusIamApiV2Tests.groovy @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.api + +import com.fasterxml.jackson.databind.ObjectMapper +import com.nike.cerberus.util.PropUtils +import com.nike.cerberus.api.util.TestUtils +import org.apache.commons.lang3.StringUtils +import org.codehaus.groovy.util.StringUtil +import org.testng.annotations.AfterTest +import org.testng.annotations.BeforeTest +import org.testng.annotations.Test + +import java.security.NoSuchAlgorithmException + +import static com.nike.cerberus.api.CerberusCompositeApiActions.* +import static com.nike.cerberus.api.CerberusApiActions.* +import static com.nike.cerberus.api.util.TestUtils.generateRandomSdbDescription + +class CerberusIamApiV2Tests { + + private String accountId + private String roleName + private String region + private String cerberusAuthToken + private def cerberusAuthData + + private ObjectMapper mapper + + @BeforeTest + void beforeTest() throws NoSuchAlgorithmException { + mapper = new ObjectMapper() + TestUtils.configureRestAssured() + loadRequiredEnvVars() + cerberusAuthData = retrieveIamAuthToken("arn:aws:iam::$accountId:role/$roleName", region) + cerberusAuthToken = cerberusAuthData."client_token" + } + + @AfterTest + void afterTest() { + deleteAuthToken(cerberusAuthToken) + } + + private void loadRequiredEnvVars() { + accountId = PropUtils.getRequiredProperty("TEST_ACCOUNT_ID", + "The account id to use when authenticating with Cerberus using the IAM Auth endpoint") + + roleName = PropUtils.getRequiredProperty("TEST_ROLE_NAME", + "The role name to use when authenticating with Cerberus using the IAM Auth endpoint") + + region = PropUtils.getRequiredProperty("TEST_REGION", + "The region to use when authenticating with Cerberus using the IAM Auth endpoint") + } + + @Test + void "test that an authenticated IAM role can create, read, update then delete a secret node"() { + "create, read, update then delete a secret node"(cerberusAuthToken) + } + + @Test + void "test that an authenticated IAM role can create, read, update, then delete a file"() { + "create, read, update then delete a file"(cerberusAuthToken) + } + + @Test + void "test that an authenticated IAM role can read secret node versions"() { + 'read secret node versions'(cerberusAuthToken) + } + + @Test + void "test that an authenticated IAM role can create, read, update then delete a safe deposit box v1"() { + "v1 create, read, list, update and then delete a safe deposit box"(cerberusAuthData) + } + + @Test + void "test that an authenticated IAM role can create, read, update then delete a safe deposit box v2"() { + "v2 create, read, list, update and then delete a safe deposit box"(cerberusAuthData) + } + + @Test + void "test that an SDB can be created with two IAM roles in permissions"() { + String iamAuthToken = cerberusAuthData.client_token + String sdbCategoryId = getCategoryMap(iamAuthToken).Applications + String sdbDescription = generateRandomSdbDescription() + String ownerRoleId = getRoleMap(iamAuthToken).owner + String iamPrincipalArn = "arn:aws:iam::$accountId:role/$roleName" + def iamPrincipalPermissions = [ + ["iam_principal_arn": iamPrincipalArn, "role_id": ownerRoleId], + ["iam_principal_arn": "arn:aws:iam::1111111111:role/fake-api-test-role", "role_id": ownerRoleId], + ] + + // create test sdb + def testSdb = createSdbV2(iamAuthToken, TestUtils.generateRandomSdbName(), sdbDescription, sdbCategoryId, iamPrincipalArn, [], iamPrincipalPermissions) + // delete test sdb + String testSdbId = testSdb.getString("id") + deleteSdb(iamAuthToken, testSdbId, V2_SAFE_DEPOSIT_BOX_PATH) + } + + @Test + void "test that IAM Root ARN permissions grant access for an IAM principal from the same account to read, update, and delete secrets"() { + String iamAuthToken = cerberusAuthData.client_token + String sdbCategoryId = getCategoryMap(iamAuthToken).Applications + String sdbDescription = generateRandomSdbDescription() + String ownerRoleId = getRoleMap(iamAuthToken).owner + String accountRootArn = "arn:aws:iam::$accountId:root" + def userPerms = [] + def iamPrincipalPermissions = [ + ["iam_principal_arn": accountRootArn, "role_id": ownerRoleId], + ] + + // create test sdb + def testSdb = createSdbV2(iamAuthToken, TestUtils.generateRandomSdbName(), sdbDescription, sdbCategoryId, "Lst-foo", userPerms, iamPrincipalPermissions) + def sdbPath = testSdb.getString("path") + sdbPath = StringUtils.removeEnd(sdbPath, "/") + "create, read, update then delete a secret node"(iamAuthToken, sdbPath) + + // delete test sdb + String testSdbId = testSdb.getString("id") + deleteSdb(iamAuthToken, testSdbId, V2_SAFE_DEPOSIT_BOX_PATH) + } + + @Test + void "test that IAM Root ARN permissions grant access for an IAM principal from the same account to read, update, and delete files"() { + String iamAuthToken = cerberusAuthData.client_token + String sdbCategoryId = getCategoryMap(iamAuthToken).Applications + String sdbDescription = generateRandomSdbDescription() + String ownerRoleId = getRoleMap(iamAuthToken).owner + String accountRootArn = "arn:aws:iam::$accountId:root" + def userPerms = [] + def iamPrincipalPermissions = [ + ["iam_principal_arn": accountRootArn, "role_id": ownerRoleId], + ] + + // create test sdb + def testSdb = createSdbV2(iamAuthToken, TestUtils.generateRandomSdbName(), sdbDescription, sdbCategoryId, "Lst-foo", userPerms, iamPrincipalPermissions) + def sdbPath = testSdb.getString("path") + sdbPath = StringUtils.removeEnd(sdbPath, "/") + "create, read, update then delete a file"(iamAuthToken, sdbPath) + + // delete test sdb + String testSdbId = testSdb.getString("id") + deleteSdb(iamAuthToken, testSdbId, V2_SAFE_DEPOSIT_BOX_PATH) + } +} diff --git a/cerberus-api-tests/src/integration-test/groovy/com/nike/cerberus/api/CerberusUserApiTests.groovy b/cerberus-api-tests/src/integration-test/groovy/com/nike/cerberus/api/CerberusUserApiTests.groovy new file mode 100644 index 000000000..c8b28c7ac --- /dev/null +++ b/cerberus-api-tests/src/integration-test/groovy/com/nike/cerberus/api/CerberusUserApiTests.groovy @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.api + +import com.nike.cerberus.api.util.TestUtils +import com.nike.cerberus.util.PropUtils +import org.testng.annotations.AfterTest +import org.testng.annotations.BeforeTest +import org.testng.annotations.Test + +import static com.nike.cerberus.api.CerberusCompositeApiActions.* +import static com.nike.cerberus.api.CerberusApiActions.* + +class CerberusUserApiTests { + + private String username + private String password + private String otpDeviceId + private String otpSecret + private String cerberusAuthToken + private Map cerberusAuthData + + @BeforeTest + void beforeTest() { + TestUtils.configureRestAssured() + loadRequiredEnvVars() + cerberusAuthData = auth() + cerberusAuthToken = cerberusAuthData.'client_token' + } + + def auth(retryCount = 0) { + try { + Map authResult = "login user with multi factor authentication (or skip mfa if not required) and return auth data"(username, password, otpSecret, otpDeviceId) + System.out.println("user login successful on try " + retryCount) + authResult + } catch (Throwable t) { + System.err.println("user login failed on try " + retryCount) + if (retryCount < 3) { + sleep(10000) + return auth(retryCount + 1) + } else {throw t} + } + } + + @AfterTest + void afterTest() { + logoutUser(cerberusAuthToken) + } + + @Test + void "test that an authenticated user can create, read, update then delete a safe deposit box v1"() { + "v1 create, read, list, update and then delete a safe deposit box"(cerberusAuthData) + } + + @Test + void "test that an authenticated user can create, read, update then delete a safe deposit box v2"() { + "v2 create, read, list, update and then delete a safe deposit box"(cerberusAuthData) + } + + @Test + void "test that an authenticated user can create, update then delete a secret node in a safe deposit box"() { + "create, read, update then delete a secret node"(cerberusAuthToken) + } + + @Test + void "test that an authenticated user can create, read, update, then delete a file"() { + "create, read, update then delete a file"(cerberusAuthToken) + } + + @Test + void "test that an authenticated user can read a preexisting secret"() { + readSecretNode(PRE_EXISTING_TEST_SECRET_PATH, cerberusAuthToken) + } + + private void loadRequiredEnvVars() { + username = PropUtils.getRequiredProperty("TEST_USER_EMAIL", + "The email address for a test user for testing user based endpoints") + + password = PropUtils.getRequiredProperty("TEST_USER_PASSWORD", + "The password for a test user for testing user based endpoints") + + // todo: make this optional + otpSecret = PropUtils.getRequiredProperty("TEST_USER_OTP_SECRET", + "The secret for the test users OTP MFA (OTP == Google auth)") + + otpDeviceId = PropUtils.getRequiredProperty("TEST_USER_OTP_DEVICE_ID", + "The device id for the test users OTP MFA (OTP == Google auth)") + } +} diff --git a/cerberus-api-tests/src/integration-test/groovy/com/nike/cerberus/api/GatewaySslSocketFactory.groovy b/cerberus-api-tests/src/integration-test/groovy/com/nike/cerberus/api/GatewaySslSocketFactory.groovy new file mode 100644 index 000000000..664f77ca8 --- /dev/null +++ b/cerberus-api-tests/src/integration-test/groovy/com/nike/cerberus/api/GatewaySslSocketFactory.groovy @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.api + +import com.nike.cerberus.util.PropUtils +import org.apache.http.conn.ssl.SSLSocketFactory +import org.apache.http.conn.ssl.X509HostnameVerifier +import org.apache.http.params.HttpParams + +import javax.net.ssl.SNIHostName +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLParameters +import javax.net.ssl.SSLSocket + +/** + * https://sookocheff.com/post/api/rest-assured-tests-against-api-gateway/ + */ +class GatewaySslSocketFactory extends SSLSocketFactory { + + GatewaySslSocketFactory(SSLContext sslContext, X509HostnameVerifier hostnameVerifier) { + super(sslContext, hostnameVerifier) + } + + @Override + Socket createSocket(HttpParams params) throws IOException { + SSLSocket sslSocket = (SSLSocket) super.createSocket(params) + + // Set the encryption protocol + String[] protocols = ["TLSv1.2"].toArray() + sslSocket.setEnabledProtocols(protocols) + + // Configure SNI + URL url = new URL(PropUtils.getRequiredProperty("CERBERUS_API_URL", "The Cerberus API URL to Test")) + SNIHostName serverName = new SNIHostName(url.getHost()) + SSLParameters sslParams = sslSocket.getSSLParameters() + sslParams.setServerNames(Collections.singletonList(serverName)) + sslSocket.setSSLParameters(sslParams) + + return sslSocket + } +} diff --git a/cerberus-api-tests/src/integration-test/groovy/com/nike/cerberus/api/InvalidAuthApiTests.groovy b/cerberus-api-tests/src/integration-test/groovy/com/nike/cerberus/api/InvalidAuthApiTests.groovy new file mode 100644 index 000000000..fe5b0e904 --- /dev/null +++ b/cerberus-api-tests/src/integration-test/groovy/com/nike/cerberus/api/InvalidAuthApiTests.groovy @@ -0,0 +1,220 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.api + +import com.nike.cerberus.api.util.TestUtils +import com.thedeanda.lorem.Lorem +import org.apache.commons.lang3.RandomStringUtils +import org.apache.http.HttpStatus +import org.testng.annotations.BeforeTest +import org.testng.annotations.Test + +import static com.nike.cerberus.api.CerberusApiActions.AUTH_TOKEN_HEADER_NAME +import static com.nike.cerberus.api.CerberusApiActions.IAM_PRINCIPAL_AUTH_PATH +import static com.nike.cerberus.api.CerberusApiActions.IAM_ROLE_AUTH_PATH +import static com.nike.cerberus.api.CerberusApiActions.SECRETS_PATH +import static com.nike.cerberus.api.CerberusApiActions.USER_AUTH_PATH +import static com.nike.cerberus.api.CerberusApiActions.USER_CREDENTIALS_HEADER_NAME +import static com.nike.cerberus.api.CerberusApiActions.V1_SAFE_DEPOSIT_BOX_PATH +import static com.nike.cerberus.api.CerberusApiActions.V2_SAFE_DEPOSIT_BOX_PATH +import static com.nike.cerberus.api.CerberusApiActions.validateDELETEApiResponse +import static com.nike.cerberus.api.CerberusApiActions.validateGETApiResponse +import static com.nike.cerberus.api.CerberusApiActions.validatePOSTApiResponse +import static com.nike.cerberus.api.CerberusApiActions.validatePUTApiResponse +import static com.nike.cerberus.api.CerberusCompositeApiActions.* + +class InvalidAuthApiTests { + + static final String INVALID_AUTH_TOKEN_STR = "invalid-auth-token" + static final FAKE_SECRET_REQUEST_URI_PATH = "$SECRETS_PATH/$ROOT_INTEGRATION_TEST_SDB_PATH/${UUID.randomUUID().toString()}" + static final V1_FAKE_SDB_PATH = "$V1_SAFE_DEPOSIT_BOX_PATH/0000-0000-0000-0000" + static final V2_FAKE_SDB_PATH = "$V1_SAFE_DEPOSIT_BOX_PATH/1111-1111-1111-1111" + static final String FAKE_ACCOUNT_ID = "1111111111" + static final String FAKE_ROLE_NAME = "fake_role" + + @BeforeTest + void beforeTest() { + TestUtils.configureRestAssured() + } + + @Test + void "test that a secret cannot be created with an invalid token"() { + def schemaFilePath = "$NEGATIVE_JSON_SCHEMA_ROOT_PATH/permission-denied-invalid-auth-token-error.json" + + validatePOSTApiResponse(INVALID_AUTH_TOKEN_STR, FAKE_SECRET_REQUEST_URI_PATH, HttpStatus.SC_UNAUTHORIZED, schemaFilePath, [value: 'value']) + } + + @Test + void "test that a secret cannot be read with an invalid token"() { + def schemaFilePath = "$NEGATIVE_JSON_SCHEMA_ROOT_PATH/permission-denied-invalid-auth-token-error.json" + + validateGETApiResponse(AUTH_TOKEN_HEADER_NAME, INVALID_AUTH_TOKEN_STR, FAKE_SECRET_REQUEST_URI_PATH, HttpStatus.SC_UNAUTHORIZED, schemaFilePath) + } + + @Test + void "test that a secret cannot be listed with an invalid token"() { + def listSecretsRequestUri = "$SECRETS_PATH/$ROOT_INTEGRATION_TEST_SDB_PATH/?list=true" + def schemaFilePath = "$NEGATIVE_JSON_SCHEMA_ROOT_PATH/permission-denied-invalid-auth-token-error.json" + + validateGETApiResponse(AUTH_TOKEN_HEADER_NAME, INVALID_AUTH_TOKEN_STR, listSecretsRequestUri, HttpStatus.SC_UNAUTHORIZED, schemaFilePath) + } + + @Test + void "test that a secret cannot be updated with an invalid token"() { + def schemaFilePath = "$NEGATIVE_JSON_SCHEMA_ROOT_PATH/permission-denied-invalid-auth-token-error.json" + + validatePOSTApiResponse(INVALID_AUTH_TOKEN_STR, FAKE_SECRET_REQUEST_URI_PATH, HttpStatus.SC_UNAUTHORIZED, schemaFilePath, [value: 'new value']) + } + + @Test + void "test that a secret cannot be delete with an invalid token"() { + def schemaFilePath = "$NEGATIVE_JSON_SCHEMA_ROOT_PATH/permission-denied-invalid-auth-token-error.json" + + validateDELETEApiResponse(INVALID_AUTH_TOKEN_STR, FAKE_SECRET_REQUEST_URI_PATH, HttpStatus.SC_UNAUTHORIZED, schemaFilePath) + } + + @Test + void "test that a v1 safe deposit box cannot be created with an invalid token"() { + String schemeFilePath = "$NEGATIVE_JSON_SCHEMA_ROOT_PATH/auth-token-is-malformed-cms-error.json" + + def iamRolePermissions = [["account_id": FAKE_ACCOUNT_ID, "iam_role_name": FAKE_ROLE_NAME, "role_id": "owner"]] + def sdb = generateSafeDepositBox(iamRolePermissions) + + validatePOSTApiResponse(INVALID_AUTH_TOKEN_STR, V1_SAFE_DEPOSIT_BOX_PATH, HttpStatus.SC_UNAUTHORIZED, schemeFilePath, sdb) + } + + @Test + void "test that a v1 safe deposit box cannot be read with an invalid token"() { + String schemeFilePath = "$NEGATIVE_JSON_SCHEMA_ROOT_PATH/auth-token-is-malformed-cms-error.json" + + validateGETApiResponse(AUTH_TOKEN_HEADER_NAME, INVALID_AUTH_TOKEN_STR, V1_FAKE_SDB_PATH, HttpStatus.SC_UNAUTHORIZED, schemeFilePath) + } + + @Test + void "test that a v1 safe deposit boxes cannot be listed with an invalid token"() { + String schemeFilePath = "$NEGATIVE_JSON_SCHEMA_ROOT_PATH/auth-token-is-malformed-cms-error.json" + + validateGETApiResponse(AUTH_TOKEN_HEADER_NAME, INVALID_AUTH_TOKEN_STR, V1_FAKE_SDB_PATH, HttpStatus.SC_UNAUTHORIZED, schemeFilePath) + } + + @Test + void "test that a v1 safe deposit box cannot be updated with an invalid token"() { + String schemeFilePath = "$NEGATIVE_JSON_SCHEMA_ROOT_PATH/auth-token-is-malformed-cms-error.json" + + def iamRolePermissions = [["account_id": "98989898989", "iam_role_name": "a-fake-role-name", "role_id": "owner"]] + def sdb = generateSafeDepositBox(iamRolePermissions) + + validatePUTApiResponse(INVALID_AUTH_TOKEN_STR, V1_FAKE_SDB_PATH, HttpStatus.SC_UNAUTHORIZED, schemeFilePath, sdb) + } + + @Test + void "test that a v1 safe deposit box cannot be deleted with an invalid token"() { + String schemeFilePath = "$NEGATIVE_JSON_SCHEMA_ROOT_PATH/auth-token-is-malformed-cms-error.json" + + validateDELETEApiResponse(INVALID_AUTH_TOKEN_STR, V1_FAKE_SDB_PATH, HttpStatus.SC_UNAUTHORIZED, schemeFilePath) + } + + + @Test + void "test that a v2 safe deposit box cannot be created with an invalid token"() { + String schemeFilePath = "$NEGATIVE_JSON_SCHEMA_ROOT_PATH/auth-token-is-malformed-cms-error.json" + + def iamRolePermissions = [["account_id": FAKE_ACCOUNT_ID, "iam_role_name": FAKE_ROLE_NAME, "role_id": "owner"]] + def sdb = generateSafeDepositBox(iamRolePermissions) + + validatePOSTApiResponse(INVALID_AUTH_TOKEN_STR, V2_SAFE_DEPOSIT_BOX_PATH, HttpStatus.SC_UNAUTHORIZED, schemeFilePath, sdb) + } + + @Test + void "test that a v2 safe deposit box cannot be read with an invalid token"() { + String schemeFilePath = "$NEGATIVE_JSON_SCHEMA_ROOT_PATH/auth-token-is-malformed-cms-error.json" + + validateGETApiResponse(AUTH_TOKEN_HEADER_NAME, INVALID_AUTH_TOKEN_STR, V2_FAKE_SDB_PATH, HttpStatus.SC_UNAUTHORIZED, schemeFilePath) + } + + @Test + void "test that a v2 safe deposit boxes cannot be listed with an invalid token"() { + String schemeFilePath = "$NEGATIVE_JSON_SCHEMA_ROOT_PATH/auth-token-is-malformed-cms-error.json" + + validateGETApiResponse(AUTH_TOKEN_HEADER_NAME, INVALID_AUTH_TOKEN_STR, V2_SAFE_DEPOSIT_BOX_PATH, HttpStatus.SC_UNAUTHORIZED, schemeFilePath) + } + + @Test + void "test that a v2 safe deposit box cannot be updated with an invalid token"() { + String schemeFilePath = "$NEGATIVE_JSON_SCHEMA_ROOT_PATH/auth-token-is-malformed-cms-error.json" + + def iamRolePermissions = [["account_id": "98989898989", "iam_role_name": "a-fake-role-name", "role_id": "owner"]] + def sdb = generateSafeDepositBox(iamRolePermissions) + + validatePUTApiResponse(INVALID_AUTH_TOKEN_STR, V2_FAKE_SDB_PATH, HttpStatus.SC_UNAUTHORIZED, schemeFilePath, sdb) + } + + @Test + void "test that a v2 safe deposit box cannot be deleted with an invalid token"() { + String schemeFilePath = "$NEGATIVE_JSON_SCHEMA_ROOT_PATH/auth-token-is-malformed-cms-error.json" + + validateDELETEApiResponse(INVALID_AUTH_TOKEN_STR, V2_FAKE_SDB_PATH, HttpStatus.SC_UNAUTHORIZED, schemeFilePath) + } + + @Test + void "an IAM role cannot auth if it does not have permission to any safe deposit box"() { + def schemaFilePath = "$NEGATIVE_JSON_SCHEMA_ROOT_PATH/iam-role-auth-no-permission-to-any-sdb.json" + def requestBody = [account_id: "0000000000", role_name: "non-existent-role-name", region: "us-west-2"] + + validatePOSTApiResponse("token not needed", IAM_ROLE_AUTH_PATH, HttpStatus.SC_BAD_REQUEST, schemaFilePath, requestBody) + } + + @Test + void "an IAM principal cannot auth if it does not have permission to any safe deposit box"() { + def schemaFilePath = "$NEGATIVE_JSON_SCHEMA_ROOT_PATH/iam-principal-auth-no-permission-to-any-sdb.json" + def requestBody = [ + iam_principal_arn: "arn:aws:iam::1111111111:role/imaginary-role-name-should-not-exist", + role_name : "non-existent-role-name", + region : "us-west-2" + ] + + validatePOSTApiResponse("token not needed", IAM_PRINCIPAL_AUTH_PATH, HttpStatus.SC_BAD_REQUEST, schemaFilePath, requestBody) + } + + @Test + void "a user cannot authenticate with invalid credentials"() { + String email = "invalid.user@example.com" + String password = "tooEZtoGuess" + def schemaFilePath = "$NEGATIVE_JSON_SCHEMA_ROOT_PATH/user-auth-invalid-credentials.json" + def credentialsHeaderValue = "Basic ${"$email:$password".bytes.encodeBase64()}" + + validateGETApiResponse(USER_CREDENTIALS_HEADER_NAME, credentialsHeaderValue, USER_AUTH_PATH, HttpStatus.SC_UNAUTHORIZED, schemaFilePath) + } + + private static Map generateSafeDepositBox(def iamPermissions) { + String name = "${RandomStringUtils.randomAlphabetic(5, 10)} ${RandomStringUtils.randomAlphabetic(5, 10)}" + String description = "${Lorem.getWords(50)}" + String categoryId = "category id" + String owner = "user group" + + def userGroupPermissions = [["name": 'foo', "role_id": "read"]] + + return [ + name : name, + description : description, + category_id : categoryId, + owner : owner, + user_group_permissions: userGroupPermissions, + iam_role_permissions : iamPermissions + ] + } +} diff --git a/cerberus-api-tests/src/integration-test/groovy/com/nike/cerberus/api/NegativeIamPermissionsApiTests.groovy b/cerberus-api-tests/src/integration-test/groovy/com/nike/cerberus/api/NegativeIamPermissionsApiTests.groovy new file mode 100644 index 000000000..5f04bd6e4 --- /dev/null +++ b/cerberus-api-tests/src/integration-test/groovy/com/nike/cerberus/api/NegativeIamPermissionsApiTests.groovy @@ -0,0 +1,352 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.api + +import com.nike.cerberus.util.PropUtils +import com.nike.cerberus.api.util.TestUtils +import io.restassured.path.json.JsonPath +import org.apache.commons.lang3.RandomStringUtils +import org.apache.commons.lang3.StringUtils +import org.apache.http.HttpStatus +import org.testng.annotations.AfterTest +import org.testng.annotations.BeforeTest +import org.testng.annotations.Test +import org.testng.collections.Maps + +import static com.nike.cerberus.api.CerberusApiActions.* +import static com.nike.cerberus.api.CerberusCompositeApiActions.NEGATIVE_JSON_SCHEMA_ROOT_PATH +import static com.nike.cerberus.api.util.TestUtils.generateRandomSdbDescription +import static com.nike.cerberus.api.util.TestUtils.generateSdbJson + +class NegativeIamPermissionsApiTests { + + private static final String PERMISSION_DENIED_JSON_SCHEMA = "$NEGATIVE_JSON_SCHEMA_ROOT_PATH/permission-denied-invalid-auth-token-error.json" + + private String accountId + private String roleName + private String region + private String iamAuthToken + + private String username + private String password + private String otpDeviceId + private String otpSecret + private String[] userGroups + private String userAuthToken + private Map userAuthData + + private Map roleMap + + private def iamPrincipalReadOnlySdb + private def iamPrincipalWriteOnlySdb + + private void loadRequiredEnvVars() { + accountId = PropUtils.getRequiredProperty("TEST_ACCOUNT_ID", + "The account id to use when authenticating with Cerberus using the IAM Auth endpoint") + + roleName = PropUtils.getRequiredProperty("TEST_ROLE_NAME", + "The role name to use when authenticating with Cerberus using the IAM Auth endpoint") + + region = PropUtils.getRequiredProperty("TEST_REGION", + "The region to use when authenticating with Cerberus using the IAM Auth endpoint") + + username = PropUtils.getRequiredProperty("TEST_USER_EMAIL", + "The email address for a test user for testing user based endpoints") + + password = PropUtils.getRequiredProperty("TEST_USER_PASSWORD", + "The password for a test user for testing user based endpoints") + + // todo: make this optional + otpSecret = PropUtils.getRequiredProperty("TEST_USER_OTP_SECRET", + "The secret for the test users OTP MFA (OTP == Google auth)") + + otpDeviceId = PropUtils.getRequiredProperty("TEST_USER_OTP_DEVICE_ID", + "The device id for the test users OTP MFA (OTP == Google auth)") + } + + @BeforeTest + void beforeTest() { + TestUtils.configureRestAssured() + loadRequiredEnvVars() + userAuthData = retrieveUserAuthToken(username, password, otpSecret, otpDeviceId) + userAuthToken = userAuthData."client_token" + userGroups = userAuthData.metadata.groups.split(/,/) + String userGroupOfTestUser = userGroups[0] + + String iamPrincipalArn = "arn:aws:iam::${accountId}:role/${roleName}" + def iamAuthData = retrieveIamAuthToken(iamPrincipalArn, region) + iamAuthToken = iamAuthData."client_token" + + String sdbCategoryId = getCategoryMap(userAuthToken).Applications + String sdbDescription = generateRandomSdbDescription() + + roleMap = getRoleMap(userAuthToken) + def readOnlyIamPrincipalPermissions = [["iam_principal_arn": iamPrincipalArn, "role_id": roleMap.read]] + def writeOnlyIamPrincipalPermissions = [["iam_principal_arn": iamPrincipalArn, "role_id": roleMap.read]] + iamPrincipalReadOnlySdb = createSdbV2(userAuthToken, TestUtils.generateRandomSdbName(), sdbDescription, sdbCategoryId, userGroupOfTestUser, [], readOnlyIamPrincipalPermissions) + iamPrincipalWriteOnlySdb = createSdbV2(userAuthToken, TestUtils.generateRandomSdbName(), sdbDescription, sdbCategoryId, userGroupOfTestUser, [], writeOnlyIamPrincipalPermissions) + } + + @AfterTest + void afterTest() { + String readOnlyIamPrincipalSdbId = iamPrincipalReadOnlySdb.getString("id") + deleteSdb(userAuthToken, readOnlyIamPrincipalSdbId, V2_SAFE_DEPOSIT_BOX_PATH) + + String writeOnlyIamPrincipalSdbId = iamPrincipalWriteOnlySdb.getString("id") + deleteSdb(userAuthToken, writeOnlyIamPrincipalSdbId, V2_SAFE_DEPOSIT_BOX_PATH) + + logoutUser(userAuthToken) + deleteAuthToken(iamAuthToken) + } + + @Test + void "test that a read IAM principal cannot edit permissions"() { + def sdbId = iamPrincipalReadOnlySdb.getString("id") + def roleMap = getRoleMap(userAuthToken) + def fake_arn = "arn:aws:iam::0011001100:user/obviously-fake-test-user" + + def newIamPrincipalPermissions = [["iam_principal_arn": fake_arn, "role_id": roleMap.owner]] + def updateSdbJson = generateSdbJson( + iamPrincipalReadOnlySdb.getString("description"), + iamPrincipalReadOnlySdb.getString("owner"), + iamPrincipalReadOnlySdb.get("user_group_permissions"), + newIamPrincipalPermissions) + def updateSdbRequestUri = "$V2_SAFE_DEPOSIT_BOX_PATH/$sdbId" + String schemaFilePath = "$NEGATIVE_JSON_SCHEMA_ROOT_PATH/ownership-required-permissions-error.json" + + // update SDB + validatePUTApiResponse(iamAuthToken, updateSdbRequestUri, HttpStatus.SC_FORBIDDEN, schemaFilePath, updateSdbJson) + } + + @Test + void "test that a read IAM principal cannot update the SDB owner"() { + def sdbId = iamPrincipalReadOnlySdb.getString("id") + def newOwner = "new-owner-group" + + def updateSdbJson = generateSdbJson( + iamPrincipalReadOnlySdb.getString("description"), + newOwner, + iamPrincipalReadOnlySdb.get("user_group_permissions"), + iamPrincipalReadOnlySdb.get("iam_principal_permissions")) + def updateSdbRequestUri = "$V2_SAFE_DEPOSIT_BOX_PATH/$sdbId" + String schemaFilePath = "$NEGATIVE_JSON_SCHEMA_ROOT_PATH/ownership-required-permissions-error.json" + + // update SDB + validatePUTApiResponse(iamAuthToken, updateSdbRequestUri, HttpStatus.SC_FORBIDDEN, schemaFilePath, updateSdbJson) + } + + @Test + void "test that a read IAM principal cannot write a secret"() { + String sdbPath = iamPrincipalReadOnlySdb.getString("path") + sdbPath = StringUtils.substringBeforeLast(sdbPath, "/") + + def writeSecretRequestUri = "$SECRETS_PATH/$sdbPath/${UUID.randomUUID().toString()}" + + // create secret + validatePOSTApiResponse(iamAuthToken, writeSecretRequestUri, HttpStatus.SC_FORBIDDEN, PERMISSION_DENIED_JSON_SCHEMA, [value: 'value']) + } + + @Test + void "test that a read IAM principal cannot delete the SDB V2"() { + def sdbId = iamPrincipalReadOnlySdb.getString("id") + def deleteSdbRequestUri = "$V2_SAFE_DEPOSIT_BOX_PATH/$sdbId" + + validateDELETEApiResponse(iamAuthToken, deleteSdbRequestUri, HttpStatus.SC_FORBIDDEN, PERMISSION_DENIED_JSON_SCHEMA) + } + + @Test + void "test that a read IAM principal cannot delete the SDB V1"() { + def sdbId = iamPrincipalReadOnlySdb.getString("id") + def deleteSdbRequestUri = "$V1_SAFE_DEPOSIT_BOX_PATH/$sdbId" + + validateDELETEApiResponse(iamAuthToken, deleteSdbRequestUri, HttpStatus.SC_FORBIDDEN, PERMISSION_DENIED_JSON_SCHEMA) + } + + + @Test + void "test that a write IAM principal cannot edit permissions"() { + def sdbId = iamPrincipalWriteOnlySdb.getString("id") + def roleMap = getRoleMap(userAuthToken) + def fake_arn = "arn:aws:iam::0011001100:user/obviously-fake-test-user" + + def newIamPrincipalPermissions = [["iam_principal_arn": fake_arn, "role_id": roleMap.owner]] + def updateSdbJson = generateSdbJson( + iamPrincipalWriteOnlySdb.getString("description"), + iamPrincipalWriteOnlySdb.getString("owner"), + iamPrincipalWriteOnlySdb.get("user_group_permissions"), + newIamPrincipalPermissions) + def updateSdbRequestUri = "$V2_SAFE_DEPOSIT_BOX_PATH/$sdbId" + String schemaFilePath = "$NEGATIVE_JSON_SCHEMA_ROOT_PATH/ownership-required-permissions-error.json" + + // update SDB + validatePUTApiResponse(iamAuthToken, updateSdbRequestUri, HttpStatus.SC_FORBIDDEN, schemaFilePath, updateSdbJson) + } + + @Test + void "test that a write IAM principal cannot update the SDB owner"() { + def sdbId = iamPrincipalWriteOnlySdb.getString("id") + def newOwner = "new-owner-group" + + def updateSdbJson = generateSdbJson( + iamPrincipalWriteOnlySdb.getString("description"), + newOwner, + iamPrincipalWriteOnlySdb.get("user_group_permissions"), + iamPrincipalWriteOnlySdb.get("iam_principal_permissions")) + def updateSdbRequestUri = "$V2_SAFE_DEPOSIT_BOX_PATH/$sdbId" + String schemaFilePath = "$NEGATIVE_JSON_SCHEMA_ROOT_PATH/ownership-required-permissions-error.json" + + // update SDB + validatePUTApiResponse(iamAuthToken, updateSdbRequestUri, HttpStatus.SC_FORBIDDEN, schemaFilePath, updateSdbJson) + } + + @Test + void "test that a write IAM principal cannot delete the SDB V1"() { + def sdbId = iamPrincipalWriteOnlySdb.getString("id") + def deleteSdbRequestUri = "$V1_SAFE_DEPOSIT_BOX_PATH/$sdbId" + + validateDELETEApiResponse(iamAuthToken, deleteSdbRequestUri, HttpStatus.SC_FORBIDDEN, PERMISSION_DENIED_JSON_SCHEMA) + System.out.println("After write tries to delete SDB") + } + + @Test + void "test that a write IAM principal cannot delete the SDB V2"() { + def sdbId = iamPrincipalWriteOnlySdb.getString("id") + def deleteSdbRequestUri = "$V2_SAFE_DEPOSIT_BOX_PATH/$sdbId" + + validateDELETEApiResponse(iamAuthToken, deleteSdbRequestUri, HttpStatus.SC_FORBIDDEN, PERMISSION_DENIED_JSON_SCHEMA) + System.out.println("After write tries to delete SDB") + } + + @Test + void "test that a write IAM principal cannot call refresh endpoint"() { + validateGETApiResponse( + AUTH_TOKEN_HEADER_NAME, + iamAuthToken, + "v2/auth/user/refresh", + HttpStatus.SC_FORBIDDEN, + "$NEGATIVE_JSON_SCHEMA_ROOT_PATH/requested-resource-for-user-principals-only.json") + } + + @Test + void "test that a non admin IAM principal cannot call PUT v1 metadata endpoint"() { + String schemaFilePath = "$NEGATIVE_JSON_SCHEMA_ROOT_PATH/access-to-requested-resource-is-denied.json" + // call PUT metadata + validatePUTApiResponse(iamAuthToken, "v1/metadata", HttpStatus.SC_FORBIDDEN, schemaFilePath, Maps.newHashMap()) + } + + @Test + void "test that a non admin IAM principal cannot call GET v1 metadata endpoint"() { + String schemaFilePath = "$NEGATIVE_JSON_SCHEMA_ROOT_PATH/access-to-requested-resource-is-denied.json" + // call PUT metadata + validateGETApiResponse(AUTH_TOKEN_HEADER_NAME, iamAuthToken, "v1/metadata", HttpStatus.SC_FORBIDDEN, schemaFilePath) + } + + @Test + void "test that IAM Root ARN permissions do not grant access to an IAM principal from a different account"() { + String sdbCategoryId = getCategoryMap(iamAuthToken).Applications + String sdbDescription = generateRandomSdbDescription() + String ownerRoleId = getRoleMap(iamAuthToken).owner + String accountRootArn = "arn:aws:iam::00000000:root" + String automationUserGroup = userGroups[0] + def userPerms = [] + def iamPrincipalPermissions = [ + ["iam_principal_arn": accountRootArn, "role_id": ownerRoleId], + ] + + // create test sdb + def testSdb = createSdbV2(iamAuthToken, TestUtils.generateRandomSdbName(), sdbDescription, sdbCategoryId, automationUserGroup, userPerms, iamPrincipalPermissions) + def testSecretName = "${RandomStringUtils.randomAlphabetic(5,10)} ${RandomStringUtils.randomAlphabetic(5,10)}" + + // create test secret to read + def secret = ["foo": "bar"] + def secretPath = "${testSdb.getString("path")}${testSecretName}" + createOrUpdateSecretNode(secret, secretPath, userAuthToken) + + // test that principal cannot read + validateGETApiResponse( + AUTH_TOKEN_HEADER_NAME, + iamAuthToken, + "v1/secret/${secretPath}", + HttpStatus.SC_FORBIDDEN, + "$NEGATIVE_JSON_SCHEMA_ROOT_PATH/permission-denied-invalid-auth-token-error.json") + + // delete test sdb + String testSdbId = testSdb.getString("id") + deleteSdb(userAuthToken, testSdbId, V2_SAFE_DEPOSIT_BOX_PATH) + } + + @Test + void "test that IAM Root ARN permissions do not grant access to a IAM principal with permissions to a different SDB"() { + String sdbCategoryId = getCategoryMap(iamAuthToken).Applications + String sdbDescription = generateRandomSdbDescription() + String ownerRoleId = getRoleMap(iamAuthToken).owner + String accountRootWithNoAccess = "arn:aws:iam::00000000:root" + String accountRootWithAccess = "arn:aws:iam::$accountId:root" + String automationUserGroup = userGroups[0] + def userPerms = [] + def iamPermsWithNoAccess = [ + ["iam_principal_arn": accountRootWithNoAccess, "role_id": ownerRoleId], + ] + def iamPermsWithAccess = [ + ["iam_principal_arn": accountRootWithAccess, "role_id": ownerRoleId], + ] + + // create test sdb + def sdbWithNoAccess = createSdbV2(iamAuthToken, TestUtils.generateRandomSdbName(), sdbDescription, sdbCategoryId, automationUserGroup, userPerms, iamPermsWithNoAccess) + def sdbWithAccess = createSdbV2(iamAuthToken, TestUtils.generateRandomSdbName(), sdbDescription, sdbCategoryId, automationUserGroup, userPerms, iamPermsWithAccess) + + // create test secret to read + def secret = ["foo": "bar"] + def sdbPathWithNoAccess = sdbWithNoAccess.getString("path") + def secretPathWithNoAccess = "${sdbPathWithNoAccess}${RandomStringUtils.randomAlphabetic(5,10)}" + createOrUpdateSecretNode(secret, secretPathWithNoAccess, userAuthToken) + + // test that principal cannot read from sdb without access + validateGETApiResponse( + AUTH_TOKEN_HEADER_NAME, + iamAuthToken, + "v1/secret/${secretPathWithNoAccess}", + HttpStatus.SC_FORBIDDEN, + "$NEGATIVE_JSON_SCHEMA_ROOT_PATH/permission-denied-invalid-auth-token-error.json") + + // delete test sdbs + deleteSdb(userAuthToken, sdbWithNoAccess.getString("id"), V2_SAFE_DEPOSIT_BOX_PATH) + deleteSdb(iamAuthToken, sdbWithAccess.getString("id"), V2_SAFE_DEPOSIT_BOX_PATH) + } + + private static Map getRoleMap(String cerberusAuthToken) { + // Create a map of role ids to names + JsonPath getRolesResponse = getRoles(cerberusAuthToken) + def roleMap = [:] + getRolesResponse.getList("").each { role -> + roleMap.put role.name, role.id + } + + return roleMap + } + + private static Map getCategoryMap(String cerberusAuthToken) { + // Create a map of category ids to names' + JsonPath getCategoriesResponse = getCategories(cerberusAuthToken) + def catMap = [:] + getCategoriesResponse.getList("").each { category -> + catMap.put category.display_name, category.id + } + + return catMap + } +} diff --git a/cerberus-api-tests/src/integration-test/groovy/com/nike/cerberus/api/NegativeUserPermissionsApiTests.groovy b/cerberus-api-tests/src/integration-test/groovy/com/nike/cerberus/api/NegativeUserPermissionsApiTests.groovy new file mode 100644 index 000000000..e97de56f7 --- /dev/null +++ b/cerberus-api-tests/src/integration-test/groovy/com/nike/cerberus/api/NegativeUserPermissionsApiTests.groovy @@ -0,0 +1,263 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.api + +import com.nike.cerberus.util.PropUtils +import com.nike.cerberus.api.util.TestUtils +import com.thedeanda.lorem.Lorem +import io.restassured.path.json.JsonPath +import org.apache.commons.lang3.StringUtils +import org.apache.http.HttpStatus +import org.testng.annotations.AfterTest +import org.testng.annotations.BeforeTest +import org.testng.annotations.Test +import org.testng.collections.Maps + +import static com.nike.cerberus.api.CerberusCompositeApiActions.* +import static com.nike.cerberus.api.CerberusApiActions.* +import static com.nike.cerberus.api.util.TestUtils.generateRandomSdbDescription +import static com.nike.cerberus.api.util.TestUtils.generateSdbJson + +class NegativeUserPermissionsApiTests { + + private static final String PERMISSION_DENIED_JSON_SCHEMA = "$NEGATIVE_JSON_SCHEMA_ROOT_PATH/permission-denied-invalid-auth-token-error.json" + + private String accountId + private String roleName + private String region + private String iamAuthToken + + private String username + private String password + private String otpDeviceId + private String otpSecret + private String[] userGroups + private String userAuthToken + private Map userAuthData + + private Map roleMap + + private def userReadOnlySdb + private def userWriteOnlySdb + + private void loadRequiredEnvVars() { + accountId = PropUtils.getRequiredProperty("TEST_ACCOUNT_ID", + "The account id to use when authenticating with Cerberus using the IAM Auth endpoint") + + roleName = PropUtils.getRequiredProperty("TEST_ROLE_NAME", + "The role name to use when authenticating with Cerberus using the IAM Auth endpoint") + + region = PropUtils.getRequiredProperty("TEST_REGION", + "The region to use when authenticating with Cerberus using the IAM Auth endpoint") + + username = PropUtils.getRequiredProperty("TEST_USER_EMAIL", + "The email address for a test user for testing user based endpoints") + + password = PropUtils.getRequiredProperty("TEST_USER_PASSWORD", + "The password for a test user for testing user based endpoints") + + // todo: make this optional + otpSecret = PropUtils.getRequiredProperty("TEST_USER_OTP_SECRET", + "The secret for the test users OTP MFA (OTP == Google auth)") + + otpDeviceId = PropUtils.getRequiredProperty("TEST_USER_OTP_DEVICE_ID", + "The device id for the test users OTP MFA (OTP == Google auth)") + } + + @BeforeTest + void beforeTest() { + TestUtils.configureRestAssured() + loadRequiredEnvVars() + userAuthData = retrieveUserAuthToken(username, password, otpSecret, otpDeviceId) + String iamPrincipalArn = "arn:aws:iam::${accountId}:role/${roleName}" + def iamAuthData = retrieveIamAuthToken(iamPrincipalArn, region) + userAuthToken = userAuthData."client_token" + iamAuthToken = iamAuthData."client_token" + userGroups = userAuthData.metadata.groups.split(/,/) + String userGroupOfTestUser = userGroups[0] + + String sdbCategoryId = getCategoryMap(userAuthToken).Applications + String sdbDescription = generateRandomSdbDescription() + + roleMap = getRoleMap(userAuthToken) + def ownerIamPrincipalPermissions = [["iam_principal_arn": iamPrincipalArn, "role_id": roleMap.owner]] + def readOnlyUserGroupPermissions = [["name": userGroupOfTestUser, "role_id": roleMap.read]] + def writeOnlyUserGroupPermissions = [["name": userGroupOfTestUser, "role_id": roleMap.write]] + + userReadOnlySdb = createSdbV2(userAuthToken, TestUtils.generateRandomSdbName(), sdbDescription, sdbCategoryId, iamPrincipalArn, readOnlyUserGroupPermissions, ownerIamPrincipalPermissions) + userWriteOnlySdb = createSdbV2(userAuthToken, TestUtils.generateRandomSdbName(), sdbDescription, sdbCategoryId, iamPrincipalArn, writeOnlyUserGroupPermissions, ownerIamPrincipalPermissions) + } + + @AfterTest + void afterTest() { + String readOnlyUserSdbId = userReadOnlySdb.getString("id") + deleteSdb(iamAuthToken, readOnlyUserSdbId, V2_SAFE_DEPOSIT_BOX_PATH) + + String writeOnlyUserGroupSdbId = userWriteOnlySdb.getString("id") + deleteSdb(iamAuthToken, writeOnlyUserGroupSdbId, V2_SAFE_DEPOSIT_BOX_PATH) + + logoutUser(userAuthToken) + deleteAuthToken(iamAuthToken) + } + + @Test + void "test that a read user cannot edit permissions"() { + def sdbId = userReadOnlySdb.getString("id") + def roleMap = getRoleMap(userAuthToken) + + def newUserPermissions = [["name": 'foo', "role_id": roleMap.write]] + def updateSdbJson = generateSdbJson( + userReadOnlySdb.getString("description"), + userReadOnlySdb.getString("owner"), + newUserPermissions, + userReadOnlySdb.get("iam_principal_permissions")) + def updateSdbRequestUri = "$V2_SAFE_DEPOSIT_BOX_PATH/$sdbId" + String schemaFilePath = "$NEGATIVE_JSON_SCHEMA_ROOT_PATH/ownership-required-permissions-error.json" + + // update SDB + validatePUTApiResponse(userAuthToken, updateSdbRequestUri, HttpStatus.SC_FORBIDDEN, schemaFilePath, updateSdbJson) + } + + @Test + void "test that a read user cannot update the SDB owner"() { + def sdbId = userReadOnlySdb.getString("id") + def newOwner = "new-owner-group" + + def updateSdbJson = generateSdbJson( + userReadOnlySdb.getString("description"), + newOwner, + userReadOnlySdb.get("user_group_permissions"), + userReadOnlySdb.get("iam_principal_permissions")) + def updateSdbRequestUri = "$V2_SAFE_DEPOSIT_BOX_PATH/$sdbId" + String schemaFilePath = "$NEGATIVE_JSON_SCHEMA_ROOT_PATH/ownership-required-permissions-error.json" + + // update SDB + validatePUTApiResponse(userAuthToken, updateSdbRequestUri, HttpStatus.SC_FORBIDDEN, schemaFilePath, updateSdbJson) + } + + @Test + void "test that a read user cannot write a secret"() { + String sdbPath = userReadOnlySdb.getString("path") + sdbPath = StringUtils.substringBeforeLast(sdbPath, "/") + + def writeSecretRequestUri = "$SECRETS_PATH/$sdbPath/${UUID.randomUUID().toString()}" + + // create secret + validatePOSTApiResponse(userAuthToken, writeSecretRequestUri, HttpStatus.SC_FORBIDDEN, PERMISSION_DENIED_JSON_SCHEMA, [value: 'value']) + } + + @Test + void "test that a read user cannot delete the SDB v1"() { + def sdbId = userReadOnlySdb.getString("id") + def deleteSdbRequestUri = "$V1_SAFE_DEPOSIT_BOX_PATH/$sdbId" + + validateDELETEApiResponse(userAuthToken, deleteSdbRequestUri, HttpStatus.SC_FORBIDDEN, PERMISSION_DENIED_JSON_SCHEMA) + } + + @Test + void "test that a read user cannot delete the SDB v2"() { + def sdbId = userReadOnlySdb.getString("id") + def deleteSdbRequestUri = "$V2_SAFE_DEPOSIT_BOX_PATH/$sdbId" + + validateDELETEApiResponse(userAuthToken, deleteSdbRequestUri, HttpStatus.SC_FORBIDDEN, PERMISSION_DENIED_JSON_SCHEMA) + } + + @Test + void "test that a write user cannot edit permissions"() { + def sdbId = userWriteOnlySdb.getString("id") + def roleMap = getRoleMap(userAuthToken) + + def newUserPermissions = [["name": 'foo', "role_id": roleMap.read]] + def updateSdbJson = generateSdbJson( + userWriteOnlySdb.getString("description"), + userWriteOnlySdb.getString("owner"), + newUserPermissions, + userWriteOnlySdb.get("iam_principal_permissions")) + def updateSdbRequestUri = "$V2_SAFE_DEPOSIT_BOX_PATH/$sdbId" + String schemaFilePath = "$NEGATIVE_JSON_SCHEMA_ROOT_PATH/ownership-required-permissions-error.json" + + // update SDB + validatePUTApiResponse(userAuthToken, updateSdbRequestUri, HttpStatus.SC_FORBIDDEN, schemaFilePath, updateSdbJson) + } + + @Test + void "test that a write user cannot update the SDB owner"() { + def sdbId = userWriteOnlySdb.getString("id") + def newOwner = "new-owner-group" + + def updateSdbJson = generateSdbJson( + userWriteOnlySdb.getString("description"), + newOwner, + userWriteOnlySdb.get("user_group_permissions"), + userWriteOnlySdb.get("iam_principal_permissions")) + def updateSdbRequestUri = "$V2_SAFE_DEPOSIT_BOX_PATH/$sdbId" + String schemaFilePath = "$NEGATIVE_JSON_SCHEMA_ROOT_PATH/ownership-required-permissions-error.json" + + // update SDB + validatePUTApiResponse(userAuthToken, updateSdbRequestUri, HttpStatus.SC_FORBIDDEN, schemaFilePath, updateSdbJson) + } + + @Test + void "test that a write user cannot delete the SDB V1"() { + def sdbId = userWriteOnlySdb.getString("id") + def deleteSdbRequestUri = "$V1_SAFE_DEPOSIT_BOX_PATH/$sdbId" + + validateDELETEApiResponse(userAuthToken, deleteSdbRequestUri, HttpStatus.SC_FORBIDDEN, PERMISSION_DENIED_JSON_SCHEMA) + } + + @Test + void "test that a write user cannot delete the SDB V2"() { + def sdbId = userWriteOnlySdb.getString("id") + def deleteSdbRequestUri = "$V2_SAFE_DEPOSIT_BOX_PATH/$sdbId" + + validateDELETEApiResponse(userAuthToken, deleteSdbRequestUri, HttpStatus.SC_FORBIDDEN, PERMISSION_DENIED_JSON_SCHEMA) + } + + @Test + void "test that a user cannot call refresh more than the allotted number of times"() { + def userTokenObject = retrieveUserAuthToken(username, password, otpSecret, otpDeviceId) + String userClientToken = userTokenObject.client_token + + String allowedRefreshesStr = userTokenObject.metadata.max_refresh_count + int allowedRefreshes = Integer.parseInt(allowedRefreshesStr) + // exhaust refresh limit + for (int i = 0; i < allowedRefreshes; i++) { + userTokenObject = refreshUserAuthToken(userClientToken).data.client_token + userClientToken = userTokenObject.client_token + } + + String schemaFilePath = "$NEGATIVE_JSON_SCHEMA_ROOT_PATH/user-exceeded-limit-of-auth-token-refresh.json" + // refresh user token + validateGETApiResponse(AUTH_TOKEN_HEADER_NAME, userClientToken, USER_TOKEN_REFRESH_PATH, HttpStatus.SC_FORBIDDEN, schemaFilePath) + // logout user + logoutUser(userClientToken) + } + + @Test + void "test that a non admin user cannot call PUT v1 metadata endpoint"() { + String schemaFilePath = "$NEGATIVE_JSON_SCHEMA_ROOT_PATH/access-to-requested-resource-is-denied.json" + // call PUT metadata + validatePUTApiResponse(userAuthToken, "v1/metadata", HttpStatus.SC_FORBIDDEN, schemaFilePath, Maps.newHashMap()) + } + + @Test + void "test that a non admin user cannot call GET v1 metadata endpoint"() { + String schemaFilePath = "$NEGATIVE_JSON_SCHEMA_ROOT_PATH/access-to-requested-resource-is-denied.json" + // call PUT metadata + validateGETApiResponse(AUTH_TOKEN_HEADER_NAME, userAuthToken, "v1/metadata", HttpStatus.SC_FORBIDDEN, schemaFilePath) + } +} diff --git a/cerberus-api-tests/src/integration-test/groovy/com/nike/cerberus/api/ValidationErrorApiTests.groovy b/cerberus-api-tests/src/integration-test/groovy/com/nike/cerberus/api/ValidationErrorApiTests.groovy new file mode 100644 index 000000000..999bea6eb --- /dev/null +++ b/cerberus-api-tests/src/integration-test/groovy/com/nike/cerberus/api/ValidationErrorApiTests.groovy @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.api + +import com.nike.cerberus.util.PropUtils +import com.nike.cerberus.api.util.TestUtils +import org.apache.commons.lang3.StringUtils +import org.apache.http.HttpStatus +import org.hamcrest.Matchers +import org.testng.annotations.AfterTest +import org.testng.annotations.BeforeTest +import org.testng.annotations.Test + +import static com.nike.cerberus.api.CerberusApiActions.* +import static com.nike.cerberus.api.CerberusCompositeApiActions.NEGATIVE_JSON_SCHEMA_ROOT_PATH +import static com.nike.cerberus.api.util.TestUtils.generateRandomSdbDescription +import static io.restassured.RestAssured.given + +class ValidationErrorApiTests { + + private String accountId + private String roleName + private String region + private String iamAuthToken + + private def testSdb + + private void loadRequiredEnvVars() { + accountId = PropUtils.getRequiredProperty("TEST_ACCOUNT_ID", + "The account id to use when authenticating with Cerberus using the IAM Auth endpoint") + + roleName = PropUtils.getRequiredProperty("TEST_ROLE_NAME", + "The role name to use when authenticating with Cerberus using the IAM Auth endpoint") + + region = PropUtils.getRequiredProperty("TEST_REGION", + "The region to use when authenticating with Cerberus using the IAM Auth endpoint") + } + + @BeforeTest + void beforeTest() { + TestUtils.configureRestAssured() + loadRequiredEnvVars() + String iamPrincipalArn = "arn:aws:iam::${accountId}:role/${roleName}" + def iamAuthData = retrieveIamAuthToken(iamPrincipalArn, region) + iamAuthToken = iamAuthData."client_token" + + String sdbCategoryId = getCategoryMap(iamAuthToken).Applications + String sdbDescription = generateRandomSdbDescription() + String ownerRoleId = getRoleMap(iamAuthToken).owner + def iamPrincipalPermissions = [["iam_principal_arn": iamPrincipalArn, "role_id": ownerRoleId]] + + testSdb = createSdbV2(iamAuthToken, TestUtils.generateRandomSdbName(), sdbDescription, sdbCategoryId, iamPrincipalArn, [], iamPrincipalPermissions) + + // regenerate token to get policy for new SDB + iamAuthToken = retrieveIamAuthToken(iamPrincipalArn, region)."client_token" + } + + @AfterTest + void afterTest() { + String testSdbId = testSdb.getString("id") + deleteSdb(iamAuthToken, testSdbId, V2_SAFE_DEPOSIT_BOX_PATH) + + deleteAuthToken(iamAuthToken) + } + + @Test + void "test that list secrets returns an empty list when there are no nodes under path"() { + String sdbPath = testSdb.getString("path") + sdbPath = StringUtils.substringBeforeLast(sdbPath, "/") + + def listSecretsUri = "$SECRETS_PATH/$sdbPath/?list=true" + + // read secret + given() + .header(AUTH_TOKEN_HEADER_NAME, iamAuthToken) + .when() + .get(listSecretsUri) + .then() + .statusCode(HttpStatus.SC_OK) + .body("data.keys", Matchers.empty()) + } + + @Test + void "test that a 404 is returned when reading a secret that does not exist"() { + String sdbPath = testSdb.getString("path") + sdbPath = StringUtils.substringBeforeLast(sdbPath, "/") + String newSecretName = UUID.randomUUID().toString() + + def readSecretUri = "$SECRETS_PATH/$sdbPath/$newSecretName" + String schemaFilePath = "$NEGATIVE_JSON_SCHEMA_ROOT_PATH/secret-not-found-error.json" + + // read secret + given() + .header(AUTH_TOKEN_HEADER_NAME, iamAuthToken) + .when() + .get(readSecretUri) + .then() + .statusCode(HttpStatus.SC_NOT_FOUND) + } + + @Test + void "test that a 404 is returned when reading a secret from an SDB that does not exist"() { + def readSecretUri = "$SECRETS_PATH/sdb-does-not-exist/random-secret-name" + + // read secret + given() + .header(AUTH_TOKEN_HEADER_NAME, iamAuthToken) + .when() + .get(readSecretUri) + .then() + .statusCode(HttpStatus.SC_NOT_FOUND) + } + + @Test + void "test that a 400 is returned when creating an SDB with the same name"() { + String alreadyExistingSdbName = testSdb.getString("name") + String ownerRoleId = getRoleMap(iamAuthToken).owner + String sdbCategoryId = getCategoryMap(iamAuthToken).Applications + String sdbDescription = generateRandomSdbDescription() + + String iamPrincipalArn = "arn:aws:iam::${accountId}:role/${roleName}" + def iamPrincipalPermissions = [["iam_principal_arn": iamPrincipalArn, "role_id": ownerRoleId]] + def sdbObject = [ + category_id : sdbCategoryId, + name : alreadyExistingSdbName, + description : sdbDescription, + owner : iamPrincipalArn, + 'user_group_permissions': [], + 'iam_role_permissions' : iamPrincipalPermissions + ] + + String schemaFilePath = "$NEGATIVE_JSON_SCHEMA_ROOT_PATH/create-sdb-with-the-same-name.json" + // update SDB + validatePOSTApiResponse(iamAuthToken, V2_SAFE_DEPOSIT_BOX_PATH, HttpStatus.SC_BAD_REQUEST, schemaFilePath, sdbObject) + } +} diff --git a/cerberus-api-tests/src/integration-test/groovy/com/nike/cerberus/api/util/TestUtils.groovy b/cerberus-api-tests/src/integration-test/groovy/com/nike/cerberus/api/util/TestUtils.groovy new file mode 100644 index 000000000..fa23daf16 --- /dev/null +++ b/cerberus-api-tests/src/integration-test/groovy/com/nike/cerberus/api/util/TestUtils.groovy @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.api.util + +import com.nike.cerberus.util.PropUtils +import com.thedeanda.lorem.Lorem +import org.apache.commons.lang3.RandomStringUtils + +import java.security.NoSuchAlgorithmException + +import static io.restassured.RestAssured.* + +class TestUtils { + + private static boolean hasBeenConfigured = false; + + private TestUtils() { + // no constructing + } + + static void configureRestAssured() throws NoSuchAlgorithmException { + if (!hasBeenConfigured) { + baseURI = PropUtils.getRequiredProperty("CERBERUS_API_URL", "The Cerberus API URL to Test") + + System.out.println("Configuring rest assured to use baseURI: " + baseURI) + + enableLoggingOfRequestAndResponseIfValidationFails() + + // allow us to ping instances directly and not go through the load balancer + useRelaxedHTTPSValidation() + + config.getHttpClientConfig().reuseHttpClientInstance() + + System.out.print("Performing sanity check get on the health check.") + get(baseURI + "/healthcheck").then().statusCode(200) + System.out.println(" Success!") + hasBeenConfigured = true + } + } + + static String generateRandomSdbName() { + return "${RandomStringUtils.randomAlphabetic(5,10)} ${RandomStringUtils.randomAlphabetic(5,10)}" + } + + static String generateRandomSdbDescription() { + return "${Lorem.getWords(50)}" + } + + static Map generateSdbJson(String description, + String owner, + List> userGroupPermissions, + List> iamPrincipalPermissions) { + return [ + description: description, + owner: owner, + 'user_group_permissions': userGroupPermissions, + 'iam_role_permissions': iamPrincipalPermissions + ] + } +} diff --git a/cerberus-api-tests/src/integration-test/resources/example-file.pem b/cerberus-api-tests/src/integration-test/resources/example-file.pem new file mode 100644 index 000000000..02935d5fb --- /dev/null +++ b/cerberus-api-tests/src/integration-test/resources/example-file.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEogIBAAKCAQEAoMZ4tIiQFhS+JZL7GT7I7bGXcG5zrasveNTWwil3D04QWfoL +/mCUsl0nqH6f96b0d6HcCPqXZRt2RxshW6VJFtvNQrPkdIbPIl3PGDBRsA7fbY/r +M/VoAOMQDJFvHvhz3gP1i+hOA1eZND/VuAfS/7+XO4Mvw0A1nh/MYD7KydnqmPje +4mbvO4E2T9c1RFXCFQH+71chqxSyWpqhMsw9LtutgJ/iOr3ORbZX1EhIVngKGAth +caJ9kOZhJZaYl6wX5TBIFP1bhR1w50mPgsluuql+tN6Nxyfs/DIZoTImZ0RweA/w +yakXcr4riWSLcmSLSyDmPOohvHvEZCoZqEJ3+QIDAQABAoIBABNfdwICBqKygysR +5/Hlz95cbOweUxAVNZYwx2QEDRWqCWxeJf3T83b+pJ24DFySIJkdxt7KG14SjhEJ +Yt6hLwpzF+u2s4ubBvCULzUx9VXqpN+V3PiY1Jxuv+tTxvkLZSLWhUsDhgr/DjXu +jzkqsDEjC/0e1K2JWiyglkbuqg7iYh3S0K6gsxKL5XTT8nAvIWbdm7poP2OEMKIU +3IHn0ZICWH8fLs8JWDydGb+v4Vy/v7H2d8zM3ZlcBbQyKtRIg7cuaQ0r4sXrmVSD +0Bw07gRkM6TnEWdXAuSx6TVE7K0VDRZCimNem0jhgWU6miDwJ2QUccY2SlzwntqA ++xEuFtECgYEA/73JCJcM+8ihHpZ1smGO+Af+jRomkpunzYtcMQb7bpmt7cBFUNb+ +aWigwlCdMh90TmiEQNCXj0e8UZswk8i7KmGBy7osI2UXUVS/T0C/HDqAS0DlziET +WIEsDdSAMOrF9jD7dNeKJbEFPsac5f6SRPUOwYzQhoJs29Joz8JQSE0CgYEAoPAZ +KTQgzxGG6ZIcQlauv74DEZ4UuAsgpK3e0ftfn1svzq4+LCzsxPuDZwCVn/fk9GSb +Ivyh802eJTA3sYvC3NyZLhSSIwcVOMHMyJrUiNqpxGNA7qQGTw1UXp7BZDEAuaS+ +8jUQG8D7yv2VxttzWJRh5RBQDISeZPuzTFtTBF0CgYB87CrgPFDGSPmTJHH3oboh +pF4OdOC0qr4sJE/zTvLQ0tboFQyG8Y4y1T35zUEMM9svVdSMiJgQED2IK2NuPRZY +HGs3pghbfc0HGHue7gSseFMk712R9AwsJmTrmIrJ2lS67rBWIddElBT41jHcEx9F ++eKsXumZWupr8nrqSYPH3QKBgHe+bVok+QxYpCMBnZkWWes8e5gOIKhXTzB4VcOy +3xD4jfef2Y6Q1pIx+TEYVDuz/FyCxpMfmXgoFjThTW8C6DfgwvBwSdNaH8YdHDte +SCiTWaFPoC5vSGplJpNIV+guNbXrCE+6f4PG8RG14E0XVxlfPf9rRcQqoJfOVzfG +81vZAoGAIqkRZe5Boys48TPfJNsj0kA+uJWWCGgpP5/1DBFzvxeESa51AhzAQpCV +yHikEJlpoCobN+OVXjj3HWW81+I0A2w72QioFBYmutc8bYwUbIJt3p2McctpeEy3 +Ij2aW7sW23ngQ9L3gHYUQhBDhpZLD3nAXDq1lGzdr9IgUz43Yjw= +-----END RSA PRIVATE KEY----- \ No newline at end of file diff --git a/cerberus-api-tests/src/integration-test/resources/json-schema/negative/access-to-requested-resource-is-denied.json b/cerberus-api-tests/src/integration-test/resources/json-schema/negative/access-to-requested-resource-is-denied.json new file mode 100644 index 000000000..a61906b16 --- /dev/null +++ b/cerberus-api-tests/src/integration-test/resources/json-schema/negative/access-to-requested-resource-is-denied.json @@ -0,0 +1,49 @@ +{ + "definitions": {}, + "$schema": "http://json-schema.org/draft-06/schema#", + "$id": "http://example.com/example.json", + "type": "object", + "properties": { + "error_id": { + "$id": "/properties/error_id", + "type": "string", + "title": "The Error_id Schema.", + "description": "An explanation about the purpose of this instance.", + "default": "", + "examples": [ + "5bf03ae4-c837-4317-886b-905859cb81cc" + ] + }, + "errors": { + "$id": "/properties/errors", + "type": "array", + "items": { + "$id": "/properties/errors/items", + "type": "object", + "properties": { + "code": { + "$id": "/properties/errors/items/properties/code", + "type": "integer", + "title": "The Code Schema.", + "description": "An explanation about the purpose of this instance.", + "default": 0, + "examples": [ + 99994 + ] + }, + "message": { + "$id": "/properties/errors/items/properties/message", + "type": "string", + "title": "The Message Schema.", + "description": "An explanation about the purpose of this instance.", + "default": "", + "examples": [ + "Access to the requested resource was denied." + ], + "pattern": "Access to the requested resource was denied." + } + } + } + } + } +} diff --git a/cerberus-api-tests/src/integration-test/resources/json-schema/negative/auth-token-is-malformed-cms-error.json b/cerberus-api-tests/src/integration-test/resources/json-schema/negative/auth-token-is-malformed-cms-error.json new file mode 100644 index 000000000..43dbd5b72 --- /dev/null +++ b/cerberus-api-tests/src/integration-test/resources/json-schema/negative/auth-token-is-malformed-cms-error.json @@ -0,0 +1,48 @@ +{ + "definitions": {}, + "$schema": "http://json-schema.org/draft-06/schema#", + "$id": "http://example.com/example.json", + "type": "object", + "properties": { + "error_id": { + "$id": "/properties/error_id", + "type": "string", + "title": "The Error_id Schema.", + "description": "An explanation about the purpose of this instance.", + "default": "", + "examples": [ + "484faf82-b20b-4590-8244-0070bc200b9f" + ] + }, + "errors": { + "$id": "/properties/errors", + "type": "array", + "items": { + "$id": "/properties/errors/items", + "type": "object", + "properties": { + "code": { + "$id": "/properties/errors/items/properties/code", + "type": "integer", + "title": "The Code Schema.", + "description": "An explanation about the purpose of this instance.", + "default": 0, + "examples": [ + 99105 + ] + }, + "message": { + "$id": "/properties/errors/items/properties/message", + "type": "string", + "title": "The Message Schema.", + "description": "An explanation about the purpose of this instance.", + "default": "", + "examples": [ + "X-Vault-Token header is malformed." + ] + } + } + } + } + } +} diff --git a/cerberus-api-tests/src/integration-test/resources/json-schema/negative/create-sdb-with-the-same-name.json b/cerberus-api-tests/src/integration-test/resources/json-schema/negative/create-sdb-with-the-same-name.json new file mode 100644 index 000000000..e98279dfc --- /dev/null +++ b/cerberus-api-tests/src/integration-test/resources/json-schema/negative/create-sdb-with-the-same-name.json @@ -0,0 +1,49 @@ +{ + "definitions": {}, + "$schema": "http://json-schema.org/draft-06/schema#", + "$id": "http://example.com/example.json", + "type": "object", + "properties": { + "error_id": { + "$id": "/properties/error_id", + "type": "string", + "title": "The Error_id Schema.", + "description": "An explanation about the purpose of this instance.", + "default": "", + "examples": [ + "84fb7526-9ab0-41a0-b658-71eb3bb7a9c9" + ] + }, + "errors": { + "$id": "/properties/errors", + "type": "array", + "items": { + "$id": "/properties/errors/items", + "type": "object", + "properties": { + "code": { + "$id": "/properties/errors/items/properties/code", + "type": "integer", + "title": "The Code Schema.", + "description": "An explanation about the purpose of this instance.", + "default": 0, + "examples": [ + 99210 + ] + }, + "message": { + "$id": "/properties/errors/items/properties/message", + "type": "string", + "title": "The Message Schema.", + "description": "An explanation about the purpose of this instance.", + "default": "", + "examples": [ + "Duplicate SDB names are not allowed." + ], + "pattern": "Duplicate SDB names are not allowed." + } + } + } + } + } +} diff --git a/cerberus-api-tests/src/integration-test/resources/json-schema/negative/iam-principal-auth-no-permission-to-any-sdb.json b/cerberus-api-tests/src/integration-test/resources/json-schema/negative/iam-principal-auth-no-permission-to-any-sdb.json new file mode 100644 index 000000000..b654706ce --- /dev/null +++ b/cerberus-api-tests/src/integration-test/resources/json-schema/negative/iam-principal-auth-no-permission-to-any-sdb.json @@ -0,0 +1,48 @@ +{ + "definitions": {}, + "$schema": "http://json-schema.org/draft-06/schema#", + "$id": "http://example.com/example.json", + "type": "object", + "properties": { + "error_id": { + "$id": "/properties/error_id", + "type": "string", + "title": "The Error_id Schema.", + "description": "An explanation about the purpose of this instance.", + "default": "", + "examples": [ + "cd8756ee-6898-40d5-804b-e6831b60e757" + ] + }, + "errors": { + "$id": "/properties/errors", + "type": "array", + "items": { + "$id": "/properties/errors/items", + "type": "object", + "properties": { + "code": { + "$id": "/properties/errors/items/properties/code", + "type": "integer", + "title": "The Code Schema.", + "description": "An explanation about the purpose of this instance.", + "default": 0, + "examples": [ + 99216 + ] + }, + "message": { + "$id": "/properties/errors/items/properties/message", + "type": "string", + "title": "The Message Schema.", + "description": "An explanation about the purpose of this instance.", + "default": "", + "examples": [ + "The specified IAM principal is not valid." + ] + } + } + } + } + } +} diff --git a/cerberus-api-tests/src/integration-test/resources/json-schema/negative/iam-role-auth-no-permission-to-any-sdb.json b/cerberus-api-tests/src/integration-test/resources/json-schema/negative/iam-role-auth-no-permission-to-any-sdb.json new file mode 100644 index 000000000..da63a8065 --- /dev/null +++ b/cerberus-api-tests/src/integration-test/resources/json-schema/negative/iam-role-auth-no-permission-to-any-sdb.json @@ -0,0 +1,48 @@ +{ + "definitions": {}, + "$schema": "http://json-schema.org/draft-06/schema#", + "$id": "http://example.com/example.json", + "type": "object", + "properties": { + "error_id": { + "$id": "/properties/error_id", + "type": "string", + "title": "The Error_id Schema.", + "description": "An explanation about the purpose of this instance.", + "default": "", + "examples": [ + "1de18bec-b807-4c72-af00-b81ae4145623" + ] + }, + "errors": { + "$id": "/properties/errors", + "type": "array", + "items": { + "$id": "/properties/errors/items", + "type": "object", + "properties": { + "code": { + "$id": "/properties/errors/items/properties/code", + "type": "integer", + "title": "The Code Schema.", + "description": "An explanation about the purpose of this instance.", + "default": 0, + "examples": [ + 99216 + ] + }, + "message": { + "$id": "/properties/errors/items/properties/message", + "type": "string", + "title": "The Message Schema.", + "description": "An explanation about the purpose of this instance.", + "default": "", + "examples": [ + "The specified IAM principal is not valid." + ] + } + } + } + } + } +} diff --git a/cerberus-api-tests/src/integration-test/resources/json-schema/negative/ownership-required-permissions-error.json b/cerberus-api-tests/src/integration-test/resources/json-schema/negative/ownership-required-permissions-error.json new file mode 100644 index 000000000..eac924bd2 --- /dev/null +++ b/cerberus-api-tests/src/integration-test/resources/json-schema/negative/ownership-required-permissions-error.json @@ -0,0 +1,49 @@ +{ + "definitions": {}, + "$schema": "http://json-schema.org/draft-06/schema#", + "$id": "http://example.com/example.json", + "type": "object", + "properties": { + "error_id": { + "$id": "/properties/error_id", + "type": "string", + "title": "The Error_id Schema.", + "description": "An explanation about the purpose of this instance.", + "default": "", + "examples": [ + "713019ae-515e-4716-ba04-225d57fbb42a" + ] + }, + "errors": { + "$id": "/properties/errors", + "type": "array", + "items": { + "$id": "/properties/errors/items", + "type": "object", + "properties": { + "code": { + "$id": "/properties/errors/items/properties/code", + "type": "integer", + "title": "The Code Schema.", + "description": "An explanation about the purpose of this instance.", + "default": 0, + "examples": [ + 99211 + ] + }, + "message": { + "$id": "/properties/errors/items/properties/message", + "type": "string", + "title": "The Message Schema.", + "description": "An explanation about the purpose of this instance.", + "default": "", + "examples": [ + "Access to the requested resource was denied." + ], + "pattern": "Access to the requested resource was denied." + } + } + } + } + } +} diff --git a/cerberus-api-tests/src/integration-test/resources/json-schema/negative/permission-denied-invalid-auth-token-error.json b/cerberus-api-tests/src/integration-test/resources/json-schema/negative/permission-denied-invalid-auth-token-error.json new file mode 100644 index 000000000..65e6d34a1 --- /dev/null +++ b/cerberus-api-tests/src/integration-test/resources/json-schema/negative/permission-denied-invalid-auth-token-error.json @@ -0,0 +1,58 @@ +{ + "definitions": {}, + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "http://example.com/example.json", + "type": "object", + "title": "The Root Schema", + "required": [ + "error_id", + "errors" + ], + "properties": { + "error_id": { + "$id": "#/properties/error_id", + "type": "string", + "title": "The Error_id Schema", + "default": "", + "examples": [ + "f0f26056-3743-41e0-9649-3e13c4a018c3" + ], + "pattern": "^(.*)$" + }, + "errors": { + "$id": "#/properties/errors", + "type": "array", + "title": "The Errors Schema", + "items": { + "$id": "#/properties/errors/items", + "type": "object", + "title": "The Items Schema", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "$id": "#/properties/errors/items/properties/code", + "type": "integer", + "title": "The Code Schema", + "default": 0, + "examples": [ + 99105 + ] + }, + "message": { + "$id": "#/properties/errors/items/properties/message", + "type": "string", + "title": "The Message Schema", + "default": "", + "examples": [ + "X-Vault-Token or X-Cerberus-Token header is malformed or invalid." + ], + "pattern": "^(.*)$" + } + } + } + } + } +} diff --git a/cerberus-api-tests/src/integration-test/resources/json-schema/negative/put-metadata-category-null.json b/cerberus-api-tests/src/integration-test/resources/json-schema/negative/put-metadata-category-null.json new file mode 100644 index 000000000..ae72d2a28 --- /dev/null +++ b/cerberus-api-tests/src/integration-test/resources/json-schema/negative/put-metadata-category-null.json @@ -0,0 +1,49 @@ +{ + "definitions": {}, + "$schema": "http://json-schema.org/draft-06/schema#", + "$id": "http://example.com/example.json", + "type": "object", + "properties": { + "error_id": { + "$id": "/properties/error_id", + "type": "string", + "title": "The Error_id Schema.", + "description": "An explanation about the purpose of this instance.", + "default": "", + "examples": [ + "38296e6b-e3c9-4d74-b3e8-92ca6a1e70ee" + ] + }, + "errors": { + "$id": "/properties/errors", + "type": "array", + "items": { + "$id": "/properties/errors/items", + "type": "object", + "properties": { + "code": { + "$id": "/properties/errors/items/properties/code", + "type": "integer", + "title": "The Code Schema.", + "description": "An explanation about the purpose of this instance.", + "default": 0, + "examples": [ + 99996 + ] + }, + "message": { + "$id": "/properties/errors/items/properties/message", + "type": "string", + "title": "The Message Schema.", + "description": "An explanation about the purpose of this instance.", + "default": "", + "examples": [ + "The category null could not be mapped to a valid category id" + ], + "pattern": "The category null could not be mapped to a valid category id" + } + } + } + } + } +} diff --git a/cerberus-api-tests/src/integration-test/resources/json-schema/negative/requested-resource-for-user-principals-only.json b/cerberus-api-tests/src/integration-test/resources/json-schema/negative/requested-resource-for-user-principals-only.json new file mode 100644 index 000000000..57c8e04dd --- /dev/null +++ b/cerberus-api-tests/src/integration-test/resources/json-schema/negative/requested-resource-for-user-principals-only.json @@ -0,0 +1,49 @@ +{ + "definitions": {}, + "$schema": "http://json-schema.org/draft-06/schema#", + "$id": "http://example.com/example.json", + "type": "object", + "properties": { + "error_id": { + "$id": "/properties/error_id", + "type": "string", + "title": "The Error_id Schema.", + "description": "An explanation about the purpose of this instance.", + "default": "", + "examples": [ + "1721d051-917c-4811-b0d7-86b5861125b3" + ] + }, + "errors": { + "$id": "/properties/errors", + "type": "array", + "items": { + "$id": "/properties/errors/items", + "type": "object", + "properties": { + "code": { + "$id": "/properties/errors/items/properties/code", + "type": "integer", + "title": "The Code Schema.", + "description": "An explanation about the purpose of this instance.", + "default": 0, + "examples": [ + 99231 + ] + }, + "message": { + "$id": "/properties/errors/items/properties/message", + "type": "string", + "title": "The Message Schema.", + "description": "An explanation about the purpose of this instance.", + "default": "", + "examples": [ + "The requested resource is for User Principals only." + ], + "pattern": "The requested resource is for User Principals only." + } + } + } + } + } +} diff --git a/cerberus-api-tests/src/integration-test/resources/json-schema/negative/secret-not-found-error.json b/cerberus-api-tests/src/integration-test/resources/json-schema/negative/secret-not-found-error.json new file mode 100644 index 000000000..bb3ffb14b --- /dev/null +++ b/cerberus-api-tests/src/integration-test/resources/json-schema/negative/secret-not-found-error.json @@ -0,0 +1,17 @@ +{ + "definitions": {}, + "$schema": "http://json-schema.org/draft-06/schema#", + "$id": "http://example.com/example.json", + "type": "object", + "properties": { + "errors": { + "$id": "/properties/errors", + "type": "array", + "items": { + "$id": "ussuribear", + "title": "Empty Object", + "description": "This accepts anything, as long as it's valid JSON." + } + } + } +} diff --git a/cerberus-api-tests/src/integration-test/resources/json-schema/negative/user-auth-invalid-credentials.json b/cerberus-api-tests/src/integration-test/resources/json-schema/negative/user-auth-invalid-credentials.json new file mode 100644 index 000000000..eec4f13d8 --- /dev/null +++ b/cerberus-api-tests/src/integration-test/resources/json-schema/negative/user-auth-invalid-credentials.json @@ -0,0 +1,48 @@ +{ + "definitions": {}, + "$schema": "http://json-schema.org/draft-06/schema#", + "$id": "http://example.com/example.json", + "type": "object", + "properties": { + "error_id": { + "$id": "/properties/error_id", + "type": "string", + "title": "The Error_id Schema.", + "description": "An explanation about the purpose of this instance.", + "default": "", + "examples": [ + "dc765355-867c-4d3a-9b84-8a75dc93a5c3" + ] + }, + "errors": { + "$id": "/properties/errors", + "type": "array", + "items": { + "$id": "/properties/errors/items", + "type": "object", + "properties": { + "code": { + "$id": "/properties/errors/items/properties/code", + "type": "integer", + "title": "The Code Schema.", + "description": "An explanation about the purpose of this instance.", + "default": 0, + "examples": [ + 99106 + ] + }, + "message": { + "$id": "/properties/errors/items/properties/message", + "type": "string", + "title": "The Message Schema.", + "description": "An explanation about the purpose of this instance.", + "default": "", + "examples": [ + "Invalid credentials" + ] + } + } + } + } + } +} diff --git a/cerberus-api-tests/src/integration-test/resources/json-schema/negative/user-exceeded-limit-of-auth-token-refresh.json b/cerberus-api-tests/src/integration-test/resources/json-schema/negative/user-exceeded-limit-of-auth-token-refresh.json new file mode 100644 index 000000000..ffc1c412e --- /dev/null +++ b/cerberus-api-tests/src/integration-test/resources/json-schema/negative/user-exceeded-limit-of-auth-token-refresh.json @@ -0,0 +1,49 @@ +{ + "definitions": {}, + "$schema": "http://json-schema.org/draft-06/schema#", + "$id": "http://example.com/example.json", + "type": "object", + "properties": { + "error_id": { + "$id": "/properties/error_id", + "type": "string", + "title": "The Error_id Schema.", + "description": "An explanation about the purpose of this instance.", + "default": "", + "examples": [ + "70b4c2e4-c3ae-4097-9208-fca1f41c3fd0" + ] + }, + "errors": { + "$id": "/properties/errors", + "type": "array", + "items": { + "$id": "/properties/errors/items", + "type": "object", + "properties": { + "code": { + "$id": "/properties/errors/items/properties/code", + "type": "integer", + "title": "The Code Schema.", + "description": "An explanation about the purpose of this instance.", + "default": 0, + "examples": [ + 99230 + ] + }, + "message": { + "$id": "/properties/errors/items/properties/message", + "type": "string", + "title": "The Message Schema.", + "description": "An explanation about the purpose of this instance.", + "default": "", + "examples": [ + "Maximum token refresh count reached, re-authentication required." + ], + "pattern": "Maximum token refresh count reached, re-authentication required." + } + } + } + } + } +} diff --git a/cerberus-api-tests/src/integration-test/resources/json-schema/v1/auth/iam-role-decrypted.json b/cerberus-api-tests/src/integration-test/resources/json-schema/v1/auth/iam-role-decrypted.json new file mode 100644 index 000000000..327ea3f05 --- /dev/null +++ b/cerberus-api-tests/src/integration-test/resources/json-schema/v1/auth/iam-role-decrypted.json @@ -0,0 +1,51 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "properties": { + "client_token": { + "type": "string" + }, + "policies": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadata": { + "type": "object", + "properties": { + "aws_region": { + "type": "string" + }, + "username": { + "type": "string" + }, + "is_admin": { + "type": "string" + }, + "groups": { + "type": "string" + }, + "aws_account_id": { + "type": "string" + }, + "aws_iam_role_name": { + "type": "string" + } + } + }, + "lease_duration": { + "type": "integer" + }, + "renewable": { + "type": "boolean" + } + }, + "required": [ + "client_token", + "policies", + "metadata", + "lease_duration", + "renewable" + ] +} diff --git a/cerberus-api-tests/src/integration-test/resources/json-schema/v1/auth/iam-role-encrypted.json b/cerberus-api-tests/src/integration-test/resources/json-schema/v1/auth/iam-role-encrypted.json new file mode 100644 index 000000000..b36657fb5 --- /dev/null +++ b/cerberus-api-tests/src/integration-test/resources/json-schema/v1/auth/iam-role-encrypted.json @@ -0,0 +1,12 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "properties": { + "auth_data": { + "type": "string" + } + }, + "required": [ + "auth_data" + ] +} diff --git a/cerberus-api-tests/src/integration-test/resources/json-schema/v1/category/get_categories_success.json b/cerberus-api-tests/src/integration-test/resources/json-schema/v1/category/get_categories_success.json new file mode 100644 index 000000000..92a54de81 --- /dev/null +++ b/cerberus-api-tests/src/integration-test/resources/json-schema/v1/category/get_categories_success.json @@ -0,0 +1,49 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "definitions": {}, + "id": "http://example.com/example.json", + "items": { + "id": "/items", + "properties": { + "created_by": { + "id": "/items/properties/created_by", + "type": "string" + }, + "created_ts": { + "id": "/items/properties/created_ts", + "type": "string" + }, + "display_name": { + "id": "/items/properties/display_name", + "type": "string" + }, + "id": { + "id": "/items/properties/id", + "type": "string" + }, + "last_updated_by": { + "id": "/items/properties/last_updated_by", + "type": "string" + }, + "last_updated_ts": { + "id": "/items/properties/last_updated_ts", + "type": "string" + }, + "path": { + "id": "/items/properties/path", + "type": "string" + } + }, + "required": [ + "created_ts", + "display_name", + "created_by", + "last_updated_by", + "path", + "id", + "last_updated_ts" + ], + "type": "object" + }, + "type": "array" +} diff --git a/cerberus-api-tests/src/integration-test/resources/json-schema/v1/role/get_roles_success.json b/cerberus-api-tests/src/integration-test/resources/json-schema/v1/role/get_roles_success.json new file mode 100644 index 000000000..f5305bb66 --- /dev/null +++ b/cerberus-api-tests/src/integration-test/resources/json-schema/v1/role/get_roles_success.json @@ -0,0 +1,44 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "definitions": {}, + "id": "http://example.com/example.json", + "items": { + "id": "/items", + "properties": { + "created_by": { + "id": "/items/properties/created_by", + "type": "string" + }, + "created_ts": { + "id": "/items/properties/created_ts", + "type": "string" + }, + "id": { + "id": "/items/properties/id", + "type": "string" + }, + "last_updated_by": { + "id": "/items/properties/last_updated_by", + "type": "string" + }, + "last_updated_ts": { + "id": "/items/properties/last_updated_ts", + "type": "string" + }, + "name": { + "id": "/items/properties/name", + "type": "string" + } + }, + "required": [ + "created_ts", + "name", + "created_by", + "last_updated_by", + "id", + "last_updated_ts" + ], + "type": "object" + }, + "type": "array" +} diff --git a/cerberus-api-tests/src/integration-test/resources/json-schema/v1/safe-deposit-box/create_success.json b/cerberus-api-tests/src/integration-test/resources/json-schema/v1/safe-deposit-box/create_success.json new file mode 100644 index 000000000..da5d47d9b --- /dev/null +++ b/cerberus-api-tests/src/integration-test/resources/json-schema/v1/safe-deposit-box/create_success.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "additionalProperties": false, + "definitions": {}, + "id": "http://example.com/example.json", + "properties": { + "id": { + "id": "/properties/id", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" +} diff --git a/cerberus-api-tests/src/integration-test/resources/json-schema/v1/safe-deposit-box/list_success.json b/cerberus-api-tests/src/integration-test/resources/json-schema/v1/safe-deposit-box/list_success.json new file mode 100644 index 000000000..0f542fa7d --- /dev/null +++ b/cerberus-api-tests/src/integration-test/resources/json-schema/v1/safe-deposit-box/list_success.json @@ -0,0 +1,34 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "definitions": {}, + "id": "http://example.com/example.json", + "items": { + "id": "/items", + "properties": { + "category_id": { + "id": "/items/properties/category_id", + "type": "string" + }, + "id": { + "id": "/items/properties/id", + "type": "string" + }, + "name": { + "id": "/items/properties/name", + "type": "string" + }, + "path": { + "id": "/items/properties/path", + "type": "string" + } + }, + "required": [ + "path", + "category_id", + "id", + "name" + ], + "type": "object" + }, + "type": "array" +} diff --git a/cerberus-api-tests/src/integration-test/resources/json-schema/v1/safe-deposit-box/read_success.json b/cerberus-api-tests/src/integration-test/resources/json-schema/v1/safe-deposit-box/read_success.json new file mode 100644 index 000000000..e226258e3 --- /dev/null +++ b/cerberus-api-tests/src/integration-test/resources/json-schema/v1/safe-deposit-box/read_success.json @@ -0,0 +1,94 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "definitions": {}, + "id": "http://example.com/example.json", + "properties": { + "category_id": { + "id": "/properties/category_id", + "type": "string" + }, + "created_by": { + "id": "/properties/created_by", + "type": "string" + }, + "created_ts": { + "id": "/properties/created_ts", + "type": "string" + }, + "description": { + "id": "/properties/description", + "type": "string" + }, + "iam_role_permissions": { + "id": "/properties/iam_role_permissions", + "items": { + "id": "/properties/iam_role_permissions/items", + "properties": { + "account_id": { + "id": "/properties/iam_role_permissions/items/properties/account_id", + "type": "string" + }, + "iam_role_name": { + "id": "/properties/iam_role_permissions/items/properties/iam_role_name", + "type": "string" + }, + "role_id": { + "id": "/properties/iam_role_permissions/items/properties/role_id", + "type": "string" + } + }, + "required": [ + "role_id", + "account_id", + "iam_role_name" + ], + "type": "object" + }, + "type": "array" + }, + "id": { + "id": "/properties/id", + "type": "string" + }, + "last_updated_by": { + "id": "/properties/last_updated_by", + "type": "string" + }, + "last_updated_ts": { + "id": "/properties/last_updated_ts", + "type": "string" + }, + "name": { + "id": "/properties/name", + "type": "string" + }, + "owner": { + "id": "/properties/owner", + "type": "string" + }, + "path": { + "id": "/properties/path", + "type": "string" + }, + "user_group_permissions": { + "id": "/properties/user_group_permissions", + "items": {}, + "type": "array" + } + }, + "required": [ + "created_ts", + "description", + "last_updated_ts", + "user_group_permissions", + "created_by", + "iam_role_permissions", + "last_updated_by", + "owner", + "path", + "category_id", + "id", + "name" + ], + "type": "object" +} diff --git a/cerberus-api-tests/src/integration-test/resources/json-schema/v1/secret/get-secret-version.json b/cerberus-api-tests/src/integration-test/resources/json-schema/v1/secret/get-secret-version.json new file mode 100644 index 000000000..5aa83c342 --- /dev/null +++ b/cerberus-api-tests/src/integration-test/resources/json-schema/v1/secret/get-secret-version.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "properties": { + "auth": { + "type": "null" + }, + "data": { + "type": "object", + "properties": {} + }, + "lease_duration": { + "type": "integer" + }, + "lease_id": { + "type": "string" + }, + "renewable": { + "type": "boolean" + }, + "metadata": { + "type": "object", + "required": [ + "action_principal", + "version_created_by", + "action", + "action_ts", + "version_id", + "version_created_ts" + ] + } + }, + "required": [ + "auth", + "data", + "lease_duration", + "lease_id", + "renewable", + "metadata" + ] +} diff --git a/cerberus-api-tests/src/integration-test/resources/json-schema/v1/secret/get-secret-versions-metadata.json b/cerberus-api-tests/src/integration-test/resources/json-schema/v1/secret/get-secret-versions-metadata.json new file mode 100644 index 000000000..af651e8fc --- /dev/null +++ b/cerberus-api-tests/src/integration-test/resources/json-schema/v1/secret/get-secret-versions-metadata.json @@ -0,0 +1,80 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "properties": { + "has_next": { + "type": "boolean" + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "version_count_in_result": { + "type": "integer" + }, + "total_version_count": { + "type": "integer" + }, + "secure_data_version_summaries": { + "type": "array", + "items": { + "type:": "object", + "properties": { + "id": { + "type": "string" + }, + "sdbox_id": { + "type": "string" + }, + "path": { + "type": "string" + }, + "action": { + "type": "string" + }, + "type": { + "type": "string" + }, + "size_in_bytes": { + "type": "integer" + }, + "version_created_by": { + "type": "string" + }, + "version_created_ts": { + "type": "string" + }, + "action_principal": { + "type": "string" + }, + "action_ts": { + "type": "string" + } + }, + "required": [ + "id", + "sdbox_id", + "path", + "action", + "type", + "size_in_bytes", + "version_created_by", + "version_created_ts", + "action_principal", + "action_ts" + ] + } + } + }, + "required": [ + "has_next", + "next_offset", + "limit", + "offset", + "version_count_in_result", + "total_version_count", + "secure_data_version_summaries" + ] +} diff --git a/cerberus-api-tests/src/integration-test/resources/json-schema/v1/secret/get-secret.json b/cerberus-api-tests/src/integration-test/resources/json-schema/v1/secret/get-secret.json new file mode 100644 index 000000000..5b6be6717 --- /dev/null +++ b/cerberus-api-tests/src/integration-test/resources/json-schema/v1/secret/get-secret.json @@ -0,0 +1,29 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "properties": { + "auth": { + "type": "null" + }, + "data": { + "type": "object", + "properties": {} + }, + "lease_duration": { + "type": "integer" + }, + "lease_id": { + "type": "string" + }, + "renewable": { + "type": "boolean" + } + }, + "required": [ + "auth", + "data", + "lease_duration", + "lease_id", + "renewable" + ] +} diff --git a/cerberus-api-tests/src/integration-test/resources/json-schema/v1/secure-file/list-summaries.json b/cerberus-api-tests/src/integration-test/resources/json-schema/v1/secure-file/list-summaries.json new file mode 100644 index 000000000..2a48718ab --- /dev/null +++ b/cerberus-api-tests/src/integration-test/resources/json-schema/v1/secure-file/list-summaries.json @@ -0,0 +1,144 @@ +{ + "$id": "http://example.com/example.json", + "type": "object", + "definitions": {}, + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "has_next": { + "$id": "/properties/has_next", + "type": "boolean", + "title": "The Has_next Schema ", + "default": false, + "examples": [ + false + ] + }, + "next_offset": { + "$id": "/properties/next_offset", + "type": "null", + "title": "The Next_offset Schema ", + "default": null, + "examples": [ + null + ] + }, + "limit": { + "$id": "/properties/limit", + "type": "integer", + "title": "The Limit Schema ", + "default": 0, + "examples": [ + 100 + ] + }, + "offset": { + "$id": "/properties/offset", + "type": "integer", + "title": "The Offset Schema ", + "default": 0, + "examples": [ + 0 + ] + }, + "file_count_in_result": { + "$id": "/properties/file_count_in_result", + "type": "integer", + "title": "The File_count_in_result Schema ", + "default": 0, + "examples": [ + 0 + ] + }, + "total_file_count": { + "$id": "/properties/total_file_count", + "type": "integer", + "title": "The Total_file_count Schema ", + "default": 0, + "examples": [ + 0 + ] + }, + "secure_file_summaries": { + "$id": "/properties/secure_file_summaries", + "type": "array", + "items": { + "$id": "/properties/secure_file_summaries/items", + "type": "object", + "properties": { + "sdbox_id": { + "$id": "/properties/secure_file_summaries/items/properties/sdbox_id", + "type": "string", + "title": "The Sdbox_id Schema ", + "default": "", + "examples": [ + "abcde5-8903-2098-2893-f8937e0938a" + ] + }, + "path": { + "$id": "/properties/secure_file_summaries/items/properties/path", + "type": "string", + "title": "The Path Schema ", + "default": "", + "examples": [ + "test-sdb/example.der" + ] + }, + "size_in_bytes": { + "$id": "/properties/secure_file_summaries/items/properties/size_in_bytes", + "type": "integer", + "title": "The Size_in_bytes Schema ", + "default": 0, + "examples": [ + 1562 + ] + }, + "name": { + "$id": "/properties/secure_file_summaries/items/properties/name", + "type": "string", + "title": "The Name Schema ", + "default": "", + "examples": [ + "example.der" + ] + }, + "created_by": { + "$id": "/properties/secure_file_summaries/items/properties/created_by", + "type": "string", + "title": "The Created_by Schema ", + "default": "", + "examples": [ + "arn:aws:iam::000000000000:role/example" + ] + }, + "created_ts": { + "$id": "/properties/secure_file_summaries/items/properties/created_ts", + "type": "string", + "title": "The Created_ts Schema ", + "default": "", + "examples": [ + "2018-04-22T20:14:27Z" + ] + }, + "last_updated_by": { + "$id": "/properties/secure_file_summaries/items/properties/last_updated_by", + "type": "string", + "title": "The Last_updated_by Schema ", + "default": "", + "examples": [ + "arn:aws:iam::000000000000:role/example" + ] + }, + "last_updated_ts": { + "$id": "/properties/secure_file_summaries/items/properties/last_updated_ts", + "type": "string", + "title": "The Last_updated_ts Schema ", + "default": "", + "examples": [ + "2018-04-22T19:14:27Z" + ] + } + } + } + } + } +} diff --git a/cerberus-api-tests/src/integration-test/resources/json-schema/v2/auth/iam-role-decrypted.json b/cerberus-api-tests/src/integration-test/resources/json-schema/v2/auth/iam-role-decrypted.json new file mode 100644 index 000000000..26c1c3a22 --- /dev/null +++ b/cerberus-api-tests/src/integration-test/resources/json-schema/v2/auth/iam-role-decrypted.json @@ -0,0 +1,48 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "properties": { + "client_token": { + "type": "string" + }, + "policies": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadata": { + "type": "object", + "properties": { + "aws_region": { + "type": "string" + }, + "username": { + "type": "string" + }, + "is_admin": { + "type": "string" + }, + "groups": { + "type": "string" + }, + "aws_iam_principal_arn": { + "type": "string" + } + } + }, + "lease_duration": { + "type": "integer" + }, + "renewable": { + "type": "boolean" + } + }, + "required": [ + "client_token", + "policies", + "metadata", + "lease_duration", + "renewable" + ] +} diff --git a/cerberus-api-tests/src/integration-test/resources/json-schema/v2/auth/iam-role-encrypted.json b/cerberus-api-tests/src/integration-test/resources/json-schema/v2/auth/iam-role-encrypted.json new file mode 100644 index 000000000..b36657fb5 --- /dev/null +++ b/cerberus-api-tests/src/integration-test/resources/json-schema/v2/auth/iam-role-encrypted.json @@ -0,0 +1,12 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "properties": { + "auth_data": { + "type": "string" + } + }, + "required": [ + "auth_data" + ] +} diff --git a/cerberus-api-tests/src/integration-test/resources/json-schema/v2/auth/mfa_check.json b/cerberus-api-tests/src/integration-test/resources/json-schema/v2/auth/mfa_check.json new file mode 100644 index 000000000..bb848bf3b --- /dev/null +++ b/cerberus-api-tests/src/integration-test/resources/json-schema/v2/auth/mfa_check.json @@ -0,0 +1,67 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "client_token": { + "type": "object", + "properties": { + "client_token": { + "type": "string" + }, + "policies": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadata": { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "is_admin": { + "type": "string" + }, + "groups": { + "type": "string" + } + }, + "required": [ + "username", + "is_admin", + "groups" + ] + }, + "lease_duration": { + "type": "integer" + }, + "renewable": { + "type": "boolean" + } + }, + "required": [ + "client_token", + "policies", + "metadata", + "lease_duration", + "renewable" + ] + } + }, + "required": [ + "client_token" + ] + } + }, + "required": [ + "status", + "data" + ] +} diff --git a/cerberus-api-tests/src/integration-test/resources/json-schema/v2/auth/user-mfa_req.json b/cerberus-api-tests/src/integration-test/resources/json-schema/v2/auth/user-mfa_req.json new file mode 100644 index 000000000..cd3621eee --- /dev/null +++ b/cerberus-api-tests/src/integration-test/resources/json-schema/v2/auth/user-mfa_req.json @@ -0,0 +1,55 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "user_id": { + "type": "string" + }, + "username": { + "type": "string" + }, + "state_token": { + "type": "string" + }, + "devices": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + } + }, + "client_token": { + "type": "null" + } + }, + "required": [ + "user_id", + "username", + "state_token", + "devices", + "client_token" + ] + } + }, + "required": [ + "status", + "data" + ] +} diff --git a/cerberus-api-tests/src/integration-test/resources/json-schema/v2/auth/user-success.json b/cerberus-api-tests/src/integration-test/resources/json-schema/v2/auth/user-success.json new file mode 100644 index 000000000..9898ea03f --- /dev/null +++ b/cerberus-api-tests/src/integration-test/resources/json-schema/v2/auth/user-success.json @@ -0,0 +1,62 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "client_token": { + "type": "object", + "properties": { + "client_token": { + "type": "string" + }, + "policies": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadata": { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "is_admin": { + "type": "string" + }, + "groups": { + "type": "string" + } + } + }, + "lease_duration": { + "type": "integer" + }, + "renewable": { + "type": "boolean" + } + }, + "required": [ + "client_token", + "policies", + "metadata", + "lease_duration", + "renewable" + ] + } + }, + "required": [ + "client_token" + ] + } + }, + "required": [ + "status", + "data" + ] +} diff --git a/cerberus-api-tests/src/integration-test/resources/json-schema/v2/auth/user/refresh.json b/cerberus-api-tests/src/integration-test/resources/json-schema/v2/auth/user/refresh.json new file mode 100644 index 000000000..9898ea03f --- /dev/null +++ b/cerberus-api-tests/src/integration-test/resources/json-schema/v2/auth/user/refresh.json @@ -0,0 +1,62 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "client_token": { + "type": "object", + "properties": { + "client_token": { + "type": "string" + }, + "policies": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadata": { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "is_admin": { + "type": "string" + }, + "groups": { + "type": "string" + } + } + }, + "lease_duration": { + "type": "integer" + }, + "renewable": { + "type": "boolean" + } + }, + "required": [ + "client_token", + "policies", + "metadata", + "lease_duration", + "renewable" + ] + } + }, + "required": [ + "client_token" + ] + } + }, + "required": [ + "status", + "data" + ] +} diff --git a/cerberus-api-tests/src/integration-test/resources/json-schema/v2/safe-deposit-box/create_success.json b/cerberus-api-tests/src/integration-test/resources/json-schema/v2/safe-deposit-box/create_success.json new file mode 100644 index 000000000..6f9b6c480 --- /dev/null +++ b/cerberus-api-tests/src/integration-test/resources/json-schema/v2/safe-deposit-box/create_success.json @@ -0,0 +1,89 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "definitions": {}, + "id": "http://example.com/example.json", + "properties": { + "category_id": { + "id": "/properties/category_id", + "type": "string" + }, + "created_by": { + "id": "/properties/created_by", + "type": "string" + }, + "created_ts": { + "id": "/properties/created_ts", + "type": "string" + }, + "description": { + "id": "/properties/description", + "type": "string" + }, + "iam_principal_permissions": { + "id": "/properties/iam_role_permissions", + "items": { + "id": "/properties/iam_role_permissions/items", + "properties": { + "iam_principal_arn": { + "id": "/properties/iam_role_permissions/items/properties/iam_principal_arn", + "type": "string" + }, + "role_id": { + "id": "/properties/iam_role_permissions/items/properties/role_id", + "type": "string" + } + }, + "required": [ + "role_id", + "iam_principal_arn" + ], + "type": "object" + }, + "type": "array" + }, + "id": { + "id": "/properties/id", + "type": "string" + }, + "last_updated_by": { + "id": "/properties/last_updated_by", + "type": "string" + }, + "last_updated_ts": { + "id": "/properties/last_updated_ts", + "type": "string" + }, + "name": { + "id": "/properties/name", + "type": "string" + }, + "owner": { + "id": "/properties/owner", + "type": "string" + }, + "path": { + "id": "/properties/path", + "type": "string" + }, + "user_group_permissions": { + "id": "/properties/user_group_permissions", + "items": {}, + "type": "array" + } + }, + "required": [ + "created_ts", + "description", + "last_updated_ts", + "user_group_permissions", + "created_by", + "iam_principal_permissions", + "last_updated_by", + "owner", + "path", + "category_id", + "id", + "name" + ], + "type": "object" +} diff --git a/cerberus-api-tests/src/integration-test/resources/json-schema/v2/safe-deposit-box/read_success.json b/cerberus-api-tests/src/integration-test/resources/json-schema/v2/safe-deposit-box/read_success.json new file mode 100644 index 000000000..6f9b6c480 --- /dev/null +++ b/cerberus-api-tests/src/integration-test/resources/json-schema/v2/safe-deposit-box/read_success.json @@ -0,0 +1,89 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "definitions": {}, + "id": "http://example.com/example.json", + "properties": { + "category_id": { + "id": "/properties/category_id", + "type": "string" + }, + "created_by": { + "id": "/properties/created_by", + "type": "string" + }, + "created_ts": { + "id": "/properties/created_ts", + "type": "string" + }, + "description": { + "id": "/properties/description", + "type": "string" + }, + "iam_principal_permissions": { + "id": "/properties/iam_role_permissions", + "items": { + "id": "/properties/iam_role_permissions/items", + "properties": { + "iam_principal_arn": { + "id": "/properties/iam_role_permissions/items/properties/iam_principal_arn", + "type": "string" + }, + "role_id": { + "id": "/properties/iam_role_permissions/items/properties/role_id", + "type": "string" + } + }, + "required": [ + "role_id", + "iam_principal_arn" + ], + "type": "object" + }, + "type": "array" + }, + "id": { + "id": "/properties/id", + "type": "string" + }, + "last_updated_by": { + "id": "/properties/last_updated_by", + "type": "string" + }, + "last_updated_ts": { + "id": "/properties/last_updated_ts", + "type": "string" + }, + "name": { + "id": "/properties/name", + "type": "string" + }, + "owner": { + "id": "/properties/owner", + "type": "string" + }, + "path": { + "id": "/properties/path", + "type": "string" + }, + "user_group_permissions": { + "id": "/properties/user_group_permissions", + "items": {}, + "type": "array" + } + }, + "required": [ + "created_ts", + "description", + "last_updated_ts", + "user_group_permissions", + "created_by", + "iam_principal_permissions", + "last_updated_by", + "owner", + "path", + "category_id", + "id", + "name" + ], + "type": "object" +} diff --git a/cerberus-api-tests/src/integration-test/resources/updated-file.pem b/cerberus-api-tests/src/integration-test/resources/updated-file.pem new file mode 100644 index 000000000..25f45ea44 --- /dev/null +++ b/cerberus-api-tests/src/integration-test/resources/updated-file.pem @@ -0,0 +1,15 @@ +-----BEGIN RSA PRIVATE KEY----- +MIICWgIBAAKBgGMXmpd/fdlrL106UhoWf2wYc9ZdwAAefGesckVpi2LBOJ8qMJOW +QXFqlkSDD2HU/ox0d9zZ7nUAuynRaCcpEwAvvJCzLlWhrH8blzUmpKaPtitODlCv +lBJOgxhnbTOh/VytyzLuyRjU06d0XfD564yPLc2HALNruVeOkISPPZ7bAgMBAAEC +gYA0GV/tUVnAq0su0BAEqVl7YuvchFQDfAPaml9GQXTIimCTxqHdpv1nfSvef63h +uRPQkxfGJUrrd2PwW6pQFJdpNGc/yHcUsg/TvFyjTU6J2w1AovdYZrk7PEdwmyMQ +pp1BJ2NRIj8aWcT+qOlprDsd2ps2DzYnaxzkbwdEvDMAEQJBAK+lpYX1WH+CnpyQ +u4sQ+twbF5cl0zjyYe43Kl4BZLjd0aZM2cVo42j3a+u9ng7//QRiIsUcc0PziMNg +lLmqgUkCQQCQbHnZMgav0aUlxXYnRLc2h0tgAyUDnCtLVTPoTXmXkdsZpdrRZLES +gKUyjAWjXDyUFP/JEwtu7FVX64UXKEMDAkB6BqB58jsrSrmal8pTENbW91NqRHL7 +yxhSAK1EZb9t110BJb7dMMNcdxfYzQDz9s/2rZ7uRZemi5hl88EfQ99RAkB77wp1 +Ds/cKqxsiFGHCxmpylotTyWBPo+MetYrX+ia38hD7yaij6TAOvMIOE5STEGT1Z1W +QGiMTHREGVDBeYRfAkAalynKyxHA925N7JNkQKZXh9JZY7OzqzkF/AGN5H0/YPH7 +UsTL57T45Qh1iFmOgfkz71LbA6bhu2T8bLEo+YPs +-----END RSA PRIVATE KEY----- \ No newline at end of file diff --git a/cerberus-audit-logger-athena/cerberus-audit-logger-athena.gradle b/cerberus-audit-logger-athena/cerberus-audit-logger-athena.gradle new file mode 100644 index 000000000..4d7a58a41 --- /dev/null +++ b/cerberus-audit-logger-athena/cerberus-audit-logger-athena.gradle @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +dependencies { + implementation project(":cerberus-core") + + // AWS + implementation "com.amazonaws:aws-java-sdk-s3:${versions.awsSdkVersion}" + implementation "com.amazonaws:aws-java-sdk-athena:${versions.awsSdkVersion}" + + implementation "commons-io:commons-io:2.5" + implementation group: 'com.google.guava', name: 'guava', version: '28.1-jre' + + implementation 'ch.qos.logback:logback-classic:1.2.3' + implementation 'ch.qos.logback:logback-core:1.2.3' +} diff --git a/cerberus-audit-logger-athena/src/main/java/ch/qos/logback/core/rolling/AuditLogsS3TimeBasedRollingPolicy.java b/cerberus-audit-logger-athena/src/main/java/ch/qos/logback/core/rolling/AuditLogsS3TimeBasedRollingPolicy.java new file mode 100644 index 000000000..6ca8dfe2b --- /dev/null +++ b/cerberus-audit-logger-athena/src/main/java/ch/qos/logback/core/rolling/AuditLogsS3TimeBasedRollingPolicy.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2019 Nike, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ch.qos.logback.core.rolling; + +import com.nike.cerberus.audit.logger.service.S3LogUploaderService; +import com.nike.internal.util.StringUtils; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.stream.Stream; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** Rolling policy that will copy audit logs to S3 if enabled when the logs roll. */ +@Component +public class AuditLogsS3TimeBasedRollingPolicy extends TimeBasedRollingPolicy { + + private final String bucket; + private final String bucketRegion; + private LinkedBlockingQueue logChunkFileS3Queue = new LinkedBlockingQueue<>(); + private S3LogUploaderService s3LogUploaderService = null; + + @Autowired + public AuditLogsS3TimeBasedRollingPolicy( + @Value("${cerberus.audit.athena.bucket}") String bucket, + @Value("${cerberus.audit.athena.bucketRegion}") String bucketRegion) { + this.bucket = bucket; + this.bucketRegion = bucketRegion; + } + + @Autowired + public void setS3LogUploaderService(S3LogUploaderService s3LogUploaderService) { + this.s3LogUploaderService = s3LogUploaderService; + if (logChunkFileS3Queue.size() > 0) { + Stream.generate(() -> logChunkFileS3Queue.poll()).forEach(s3LogUploaderService::ingestLog); + } + } + + private boolean isS3AuditLogCopyingEnabled() { + return StringUtils.isNotBlank(bucket) && StringUtils.isNotBlank(bucketRegion); + } + + @Override + public void rollover() throws RolloverFailure { + super.rollover(); + + if (isS3AuditLogCopyingEnabled()) { + String filename = timeBasedFileNamingAndTriggeringPolicy.getElapsedPeriodsFileName() + ".gz"; + if (s3LogUploaderService != null) { + s3LogUploaderService.ingestLog(filename); + } else { + logChunkFileS3Queue.offer(filename); + } + } + } +} diff --git a/cerberus-audit-logger-athena/src/main/java/ch/qos/logback/core/rolling/FiveMinuteRollingFileAppender.java b/cerberus-audit-logger-athena/src/main/java/ch/qos/logback/core/rolling/FiveMinuteRollingFileAppender.java new file mode 100644 index 000000000..4601d579a --- /dev/null +++ b/cerberus-audit-logger-athena/src/main/java/ch/qos/logback/core/rolling/FiveMinuteRollingFileAppender.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2019 Nike, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ch.qos.logback.core.rolling; + +import java.util.concurrent.TimeUnit; + +/** Appender that will roll the logs every 5 minutes */ +public class FiveMinuteRollingFileAppender extends RollingFileAppender { + private long start = System.currentTimeMillis(); + + @Override + public void rollover() { + long currentTime = System.currentTimeMillis(); + long maxIntervalSinceLastLoggingInMillis = TimeUnit.MINUTES.toMillis(1); + + if ((currentTime - start) >= maxIntervalSinceLastLoggingInMillis) { + super.rollover(); + start = System.currentTimeMillis(); + } + } +} diff --git a/cerberus-audit-logger-athena/src/main/java/com/nike/cerberus/audit/logger/AthenaClientFactory.java b/cerberus-audit-logger-athena/src/main/java/com/nike/cerberus/audit/logger/AthenaClientFactory.java new file mode 100644 index 000000000..5e1754534 --- /dev/null +++ b/cerberus-audit-logger-athena/src/main/java/com/nike/cerberus/audit/logger/AthenaClientFactory.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2018 Nike, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.audit.logger; + +import com.amazonaws.services.athena.AmazonAthena; +import com.amazonaws.services.athena.AmazonAthenaClient; +import com.google.common.collect.Maps; +import java.util.Map; +import org.springframework.stereotype.Component; + +@Component +public class AthenaClientFactory { + + private final Map athenaClientMap = Maps.newConcurrentMap(); + + public AmazonAthena getClient(String region) { + AmazonAthena client = athenaClientMap.get(region); + + if (client == null) { + client = AmazonAthenaClient.builder().withRegion(region).build(); + athenaClientMap.put(region, client); + } + + return client; + } +} diff --git a/cerberus-audit-logger-athena/src/main/java/com/nike/cerberus/audit/logger/S3ClientFactory.java b/cerberus-audit-logger-athena/src/main/java/com/nike/cerberus/audit/logger/S3ClientFactory.java new file mode 100644 index 000000000..5f873f30a --- /dev/null +++ b/cerberus-audit-logger-athena/src/main/java/com/nike/cerberus/audit/logger/S3ClientFactory.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2018 Nike, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.audit.logger; + +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3Client; +import com.google.common.collect.Maps; +import java.util.Map; +import org.springframework.stereotype.Component; + +@Component +public class S3ClientFactory { + + private final Map s3ClientMap = Maps.newConcurrentMap(); + + public AmazonS3 getClient(String region) { + AmazonS3 client = s3ClientMap.get(region); + + if (client == null) { + client = AmazonS3Client.builder().withRegion(region).build(); + s3ClientMap.put(region, client); + } + + return client; + } +} diff --git a/cerberus-audit-logger-athena/src/main/java/com/nike/cerberus/audit/logger/listener/AthenaLoggingEventListener.java b/cerberus-audit-logger-athena/src/main/java/com/nike/cerberus/audit/logger/listener/AthenaLoggingEventListener.java new file mode 100644 index 000000000..310e75f34 --- /dev/null +++ b/cerberus-audit-logger-athena/src/main/java/com/nike/cerberus/audit/logger/listener/AthenaLoggingEventListener.java @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2018 Nike, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.audit.logger.listener; + +import static java.time.format.DateTimeFormatter.ISO_OFFSET_DATE_TIME; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.collect.ImmutableMap; +import com.nike.cerberus.domain.CerberusAuthToken; +import com.nike.cerberus.event.AuditableEvent; +import com.nike.cerberus.event.AuditableEventContext; +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Optional; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.Logger; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationListener; +import org.springframework.stereotype.Component; + +/** + * Event listener that only cares about auditable events and outputs to a special audit log appender + * in a flat json format that is optimized for use with AWS Athena + */ +@Slf4j +@Component +public class AthenaLoggingEventListener implements ApplicationListener { + + private static final String ATHENA_AUDIT_LOGGER_NAME = "athena-audit-logger"; + private static final DateTimeFormatter ATHENA_DATE_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private static final String PARTY_LIKE_ITS_99 = "1999-01-01T01:00:00+00:00"; + private final ObjectMapper om = new ObjectMapper(); + + protected final Logger auditLogger; + + @Autowired + public AthenaLoggingEventListener(Logger auditLogger) { + this.auditLogger = auditLogger; + } + + @Override + public void onApplicationEvent(AuditableEvent event) { + getAuditableEventContext(event) + .ifPresent( + eventContext -> { + Optional cerberusPrincipal = + eventContext.getPrincipalAsCerberusPrincipal(); + + ImmutableMap flattenedAuditEvent = + ImmutableMap.builder() + .put( + "event_timestamp", + eventContext.getTimestamp().format(ATHENA_DATE_FORMATTER)) + .put("principal_name", eventContext.getPrincipalName()) + .put( + "principal_type", + cerberusPrincipal + .map(p -> cerberusPrincipal.get().getPrincipalType().getName()) + .orElse(AuditableEventContext.UNKNOWN)) + .put( + "principal_token_created", + cerberusPrincipal + .map( + p -> + cerberusPrincipal + .get() + .getCreated() + .format(ATHENA_DATE_FORMATTER)) + .orElseGet( + () -> + OffsetDateTime.parse(PARTY_LIKE_ITS_99, ISO_OFFSET_DATE_TIME) + .format(ATHENA_DATE_FORMATTER))) + .put( + "principal_token_expires", + cerberusPrincipal + .map( + p -> + cerberusPrincipal + .get() + .getExpires() + .format(ATHENA_DATE_FORMATTER)) + .orElseGet( + () -> + OffsetDateTime.parse(PARTY_LIKE_ITS_99, ISO_OFFSET_DATE_TIME) + .format(ATHENA_DATE_FORMATTER))) + .put( + "principal_is_admin", + cerberusPrincipal + .map(p -> String.valueOf(p.isAdmin())) + .orElseGet(() -> String.valueOf(false))) + .put("ip_address", eventContext.getIpAddress()) + .put("x_forwarded_for", eventContext.getXForwardedFor()) + .put("cerberus_version", eventContext.getVersion()) + .put("client_version", eventContext.getClientVersion()) + .put("http_method", eventContext.getMethod()) + .put("status_code", String.valueOf(eventContext.getStatusCode())) + .put("path", eventContext.getPath()) + .put("action", eventContext.getAction()) + .put("was_success", String.valueOf(eventContext.isSuccess())) + .put("name", eventContext.getEventName()) + .put( + "sdb_name_slug", + Optional.ofNullable(eventContext.getSdbNameSlug()) + .orElse(AuditableEventContext.UNKNOWN)) + .put("originating_class", eventContext.getOriginatingClass()) + .put("trace_id", eventContext.getTraceId()) + .build(); + + try { + auditLogger.info(om.writeValueAsString(flattenedAuditEvent)); + log.info(event.toString()); + } catch (JsonProcessingException e) { + log.error("failed to log audit event", e); + } + }); + } + + private Optional getAuditableEventContext(AuditableEvent event) { + return event.getAuditableEventContext() != null + ? Optional.of(event.getAuditableEventContext()) + : Optional.empty(); + } +} diff --git a/cerberus-audit-logger-athena/src/main/java/com/nike/cerberus/audit/logger/service/AthenaService.java b/cerberus-audit-logger-athena/src/main/java/com/nike/cerberus/audit/logger/service/AthenaService.java new file mode 100644 index 000000000..8cb384ea7 --- /dev/null +++ b/cerberus-audit-logger-athena/src/main/java/com/nike/cerberus/audit/logger/service/AthenaService.java @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2018 Nike, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.audit.logger.service; + +import com.amazonaws.AmazonClientException; +import com.amazonaws.services.athena.AmazonAthena; +import com.amazonaws.services.athena.model.ResultConfiguration; +import com.amazonaws.services.athena.model.StartQueryExecutionRequest; +import com.amazonaws.services.athena.model.StartQueryExecutionResult; +import com.nike.cerberus.audit.logger.AthenaClientFactory; +import java.util.HashSet; +import java.util.Set; +import javax.validation.constraints.NotBlank; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class AthenaService { + + private final Logger log = LoggerFactory.getLogger(this.getClass()); + + @NotBlank private static final String TABLE_TEMPLATE = "%s_audit_db.audit_data"; + + private final String environmentName; + private final AthenaClientFactory athenaClientFactory; + private Set partitions = new HashSet<>(); + + @Autowired + public AthenaService( + @Value("${cerberus.environmentName}") String environmentName, + AthenaClientFactory athenaClientFactory) { + + this.environmentName = environmentName; + this.athenaClientFactory = athenaClientFactory; + } + + public void addPartitionIfMissing( + String region, String bucket, String year, String month, String day, String hour) { + String partition = String.format("year=%s/month=%s/day=%s/hour=%s", year, month, day, hour); + String table = String.format(TABLE_TEMPLATE, environmentName); + if (!partitions.contains(partition)) { + try { + String query = + String.format( + "ALTER TABLE %s ADD PARTITION (year='%s', month='%s', day='%s', hour='%s') " + + "LOCATION 's3://%s/audit-logs/partitioned/year=%s/month=%s/day=%s/hour=%s'", + table, year, month, day, hour, bucket, year, month, day, hour); + + AmazonAthena athena = athenaClientFactory.getClient(region); + + StartQueryExecutionResult result = + athena.startQueryExecution( + new StartQueryExecutionRequest() + .withQueryString(query) + .withResultConfiguration( + new ResultConfiguration() + .withOutputLocation(String.format("s3://%s/results/", bucket)))); + log.debug( + "Started query: '{}' to add partition: '{}' to table: '{}'", + result.getQueryExecutionId(), + partition, + table); + partitions.add(partition); + } catch (AmazonClientException e) { + log.error( + "Failed to start add partition query for year={}/month={}/day={}/hour={}", + year, + month, + day, + hour, + e); + } + } + } +} diff --git a/cerberus-audit-logger-athena/src/main/java/com/nike/cerberus/audit/logger/service/S3LogUploaderService.java b/cerberus-audit-logger-athena/src/main/java/com/nike/cerberus/audit/logger/service/S3LogUploaderService.java new file mode 100644 index 000000000..7618c0058 --- /dev/null +++ b/cerberus-audit-logger-athena/src/main/java/com/nike/cerberus/audit/logger/service/S3LogUploaderService.java @@ -0,0 +1,211 @@ +/* + * Copyright (c) 2019 Nike, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.audit.logger.service; + +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.rolling.AuditLogsS3TimeBasedRollingPolicy; +import ch.qos.logback.core.rolling.FiveMinuteRollingFileAppender; +import com.amazonaws.services.s3.AmazonS3; +import com.nike.cerberus.audit.logger.S3ClientFactory; +import java.io.File; +import java.util.Optional; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import javax.annotation.PreDestroy; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.io.FileUtils; +import org.apache.commons.io.FilenameUtils; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * Service that is intended to be created eagerly by Guice and inject itself into the LogBack + * rolling policy so that it can ingest filenames that the roller creates when it rolls audit logs + * and upload them to S3 + * + *

Because the Appenders and other Logback stuff are created before Guice, we get the policy from + * the LoggerFactory and inject this into it manually using the setter method. + */ +@Slf4j +@Component +public class S3LogUploaderService { + + private static final String ATHENA_LOG_NAME = "athena-audit-logger"; + private static final String ATHENA_LOG_APPENDER = "athena-log-appender"; + + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + private final AmazonS3 amazonS3; + private final String bucket; + private final String bucketRegion; + private final boolean athenaLoggingEventListenerEnabled; + private final AthenaService athenaService; + + @Autowired + public S3LogUploaderService( + @Value("${cerberus.audit.athena.bucket}") String bucket, + @Value("${cerberus.audit.athena.bucketRegion}") String bucketRegion, + @Value("${cerberus.audit.athena.enabled:false}") boolean athenaLoggingEventListenerEnabled, + AthenaService athenaService, + S3ClientFactory s3ClientFactory) { + this.bucket = bucket; + this.bucketRegion = bucketRegion; + this.athenaLoggingEventListenerEnabled = athenaLoggingEventListenerEnabled; + this.athenaService = athenaService; + + amazonS3 = s3ClientFactory.getClient(bucketRegion); + + // Inject this into the logback rolling policy which was created before guice land exists + getRollingPolicy() + .ifPresent( + policy -> { + log.info("S3 Rolling Policy detected injecting S3 Log Uploader Service"); + policy.setS3LogUploaderService(this); + }); + } + + /** Convenience method for sleeping */ + private void sleep(int time, TimeUnit timeUnit) { + try { + Thread.sleep(timeUnit.toMillis(time)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + /** Gets the folder structure to use in S3 to enable dt dynamic partitioning */ + protected String getPartition(String fileName) { + Pattern dtPattern = + Pattern.compile(".*?(?\\d{4})-(?\\d{2})-(?\\d{2})_(?\\d{2}).*"); + Matcher matcher = dtPattern.matcher(fileName); + if (matcher.find()) { + String year = matcher.group("year"); + String month = matcher.group("month"); + String day = matcher.group("day"); + String hour = matcher.group("hour"); + athenaService.addPartitionIfMissing(bucketRegion, bucket, year, month, day, hour); + return String.format("partitioned/year=%s/month=%s/day=%s/hour=%s", year, month, day, hour); + } else { + return "un-partitioned"; + } + } + + /** + * Asynchronously ingests logfiles and uploads them to S3 + * + * @param filename The log file that has been rolled and is ready to be uploaded to S3 + */ + public void ingestLog(String filename) { + executor.execute(() -> processLogFile(filename, 0)); + } + + /** + * Uploads a file to S3 + * + * @param filename The file to upload to s3 + * @param retryCount The retry count + */ + private void processLogFile(String filename, int retryCount) { + String filteredFilename = FilenameUtils.getName(filename); + log.info( + "process log file called with filename: {}, retry count: {}", filteredFilename, retryCount); + final File rolledLogFile = new File(filteredFilename); + // poll for 30 seconds waiting for file to exist or bail + int i = 0; + do { + sleep(1, TimeUnit.SECONDS); + log.info( + "Does '{}' exist: {}, length: {}, can read: {}, poll count: {}", + filteredFilename, + rolledLogFile.exists(), + rolledLogFile.length(), + rolledLogFile.canRead(), + i); + i++; + } while (!rolledLogFile.exists() || i >= 30); + + // if file does not exist or empty, do nothing + if (!rolledLogFile.exists() || rolledLogFile.length() == 0) { + log.error("File '{}' does not exist or is empty returning", filteredFilename); + return; + } + + String partition = getPartition(rolledLogFile.getName()); + String key = String.format("audit-logs/%s/%s", partition, rolledLogFile.getName()); + + try { + log.info("Copying log chunk to s3://{}/{}", bucket, key); + amazonS3.putObject(bucket, key, rolledLogFile); + FileUtils.deleteQuietly(rolledLogFile); + log.info("File: '{}' successfully copied and deleted.", rolledLogFile.getName()); + } catch (Exception e) { + log.error( + "Failed to copy log chunk to s3 Bucket: {} key: {} files: {}", + bucket, + key, + rolledLogFile.getName(), + e); + if (retryCount < 10) { + sleep(1, TimeUnit.SECONDS); + processLogFile(filteredFilename, retryCount + 1); + } + throw e; + } + } + + /** Shutdown hook, for ensuring the the remaining log data gets shipped to s3 on shutdown */ + @PreDestroy + public void executeServerShutdownHook() { + log.info("Shutdown event detected, telling appender to roll current log"); + getRollingPolicy().ifPresent(AuditLogsS3TimeBasedRollingPolicy::rollover); + log.info("Letting thread pool finish uploading remaining queued logs, with 10 minute timeout"); + executor.shutdown(); + log.info("Finished processing log upload queue"); + try { + executor.awaitTermination(10, TimeUnit.MINUTES); + } catch (InterruptedException e) { + log.error("Failed to wait and allow executor to finish jobs, shutting down now"); + executor.shutdownNow(); + } + } + + /** + * @return the AuditLogsS3FixedWindowRollingPolicy from the audit logger that implements + * ServerShutdownHook so that it can be registered with the shutdown hooks + */ + private Optional> getRollingPolicy() { + + if (athenaLoggingEventListenerEnabled) { + ch.qos.logback.classic.Logger auditLogger = + (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(ATHENA_LOG_NAME); + + FiveMinuteRollingFileAppender appender = + (FiveMinuteRollingFileAppender) + auditLogger.getAppender(ATHENA_LOG_APPENDER); + // TODO figure out if appender is always null and when it should not be null + if (appender != null) { + return Optional.ofNullable( + (AuditLogsS3TimeBasedRollingPolicy) appender.getRollingPolicy()); + } + } + return Optional.empty(); + } +} diff --git a/cerberus-audit-logger-athena/src/main/java/com/nike/cerberus/config/AthenaAuditLoggerConfiguration.java b/cerberus-audit-logger-athena/src/main/java/com/nike/cerberus/config/AthenaAuditLoggerConfiguration.java new file mode 100644 index 000000000..22827723c --- /dev/null +++ b/cerberus-audit-logger-athena/src/main/java/com/nike/cerberus/config/AthenaAuditLoggerConfiguration.java @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.config; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.encoder.PatternLayoutEncoder; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.rolling.AuditLogsS3TimeBasedRollingPolicy; +import ch.qos.logback.core.rolling.FiveMinuteRollingFileAppender; +import ch.qos.logback.core.util.FileSize; +import java.net.InetAddress; +import java.net.UnknownHostException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +@Configuration +@ConditionalOnProperty("cerberus.audit.athena.enabled") +@ComponentScan({"com.nike.cerberus.audit.logger"}) +@Import({AuditLogsS3TimeBasedRollingPolicy.class}) +public class AthenaAuditLoggerConfiguration { + + private static final String ATHENA_AUDIT_LOGGER_NAME = "athena-audit-logger"; + private static final String ATHENA_LOG_APPENDER_NAME = "athena-log-appender"; + private static final String MESSAGE_PATTERN = "%msg%n"; + + private final Logger athenaAuditLogger; + private final AuditLogsS3TimeBasedRollingPolicy auditLogsS3TimeBasedRollingPolicy; + + @Autowired + public AthenaAuditLoggerConfiguration( + AuditLogsS3TimeBasedRollingPolicy auditLogsS3TimeBasedRollingPolicy) { + + this.auditLogsS3TimeBasedRollingPolicy = auditLogsS3TimeBasedRollingPolicy; + + LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory(); + PatternLayoutEncoder patternLayoutEncoder = new PatternLayoutEncoder(); + patternLayoutEncoder.setPattern(MESSAGE_PATTERN); + patternLayoutEncoder.setContext(loggerContext); + patternLayoutEncoder.start(); + + String hostname; + try { + hostname = + System.getenv("HOSTNAME") != null + ? System.getenv("HOSTNAME") + : InetAddress.getLocalHost().getHostName(); + } catch (UnknownHostException e) { + throw new RuntimeException("Unable to find host name"); + } + + FiveMinuteRollingFileAppender fiveMinuteRollingFileAppender = + new FiveMinuteRollingFileAppender<>(); + fiveMinuteRollingFileAppender.setName(ATHENA_LOG_APPENDER_NAME); + fiveMinuteRollingFileAppender.setContext(loggerContext); + fiveMinuteRollingFileAppender.setFile(hostname + "-audit.log"); + fiveMinuteRollingFileAppender.setEncoder(patternLayoutEncoder); + + this.auditLogsS3TimeBasedRollingPolicy.setContext(loggerContext); + this.auditLogsS3TimeBasedRollingPolicy.setFileNamePattern( + hostname + "-audit.%d{yyyy-MM-dd-HH-mm, UTC}.log.gz"); + this.auditLogsS3TimeBasedRollingPolicy.setMaxHistory(100); + this.auditLogsS3TimeBasedRollingPolicy.setParent(fiveMinuteRollingFileAppender); + this.auditLogsS3TimeBasedRollingPolicy.setTotalSizeCap(FileSize.valueOf("10gb")); + + fiveMinuteRollingFileAppender.setTriggeringPolicy(this.auditLogsS3TimeBasedRollingPolicy); + fiveMinuteRollingFileAppender.setRollingPolicy(this.auditLogsS3TimeBasedRollingPolicy); + + this.auditLogsS3TimeBasedRollingPolicy.start(); + fiveMinuteRollingFileAppender.start(); + + var logger = (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(ATHENA_AUDIT_LOGGER_NAME); + logger.addAppender(fiveMinuteRollingFileAppender); + logger.setLevel(Level.INFO); + logger.setAdditive(false); + athenaAuditLogger = logger; + } + + @Bean + public Logger getAthenaAuditLogger() { + return athenaAuditLogger; + } +} diff --git a/cerberus-audit-logger-athena/src/test/java/com/nike/cerberus/audit/logger/service/S3LogUploaderServiceTest.java b/cerberus-audit-logger-athena/src/test/java/com/nike/cerberus/audit/logger/service/S3LogUploaderServiceTest.java new file mode 100644 index 000000000..bcc9cb9a9 --- /dev/null +++ b/cerberus-audit-logger-athena/src/test/java/com/nike/cerberus/audit/logger/service/S3LogUploaderServiceTest.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.audit.logger.service; + +import static junit.framework.TestCase.assertEquals; +import static org.mockito.MockitoAnnotations.initMocks; + +import com.nike.cerberus.audit.logger.S3ClientFactory; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; + +public class S3LogUploaderServiceTest { + + @Mock AthenaService athenaService; + + @Mock S3ClientFactory s3ClientFactory; + + private S3LogUploaderService s3LogUploader; + + @Before + public void before() { + initMocks(this); + s3LogUploader = + new S3LogUploaderService("fake-bucket", "us-west-2", true, athenaService, s3ClientFactory); + } + + @Test + public void test_that_getPartition_works() { + String fileName = "localhost-audit.2018-01-29_12-58.log.gz"; + String actual = s3LogUploader.getPartition(fileName); + String expected = "partitioned/year=2018/month=01/day=29/hour=12"; + assertEquals(expected, actual); + } +} diff --git a/cerberus-auth-connector-okta/cerberus-auth-connector-okta.gradle b/cerberus-auth-connector-okta/cerberus-auth-connector-okta.gradle new file mode 100644 index 000000000..1b926ecd2 --- /dev/null +++ b/cerberus-auth-connector-okta/cerberus-auth-connector-okta.gradle @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +dependencies { + implementation project(":cerberus-core") + + // Okta + implementation "com.okta:okta-sdk:0.0.4" + implementation "com.okta.authn.sdk:okta-authn-sdk-api:0.1.0" + implementation "com.okta.sdk:okta-sdk-httpclient:1.2.0" + implementation "com.okta.authn.sdk:okta-authn-sdk-impl:0.1.0" + + // The Okta SDKs pull in an outdated version of guava that the OWASP Dep checker doesn't like + implementation group: 'com.google.guava', name: 'guava', version: "${versions.guava}" +} diff --git a/cerberus-auth-connector-okta/src/main/java/com/nike/cerberus/auth/connector/config/OktaConfiguration.java b/cerberus-auth-connector-okta/src/main/java/com/nike/cerberus/auth/connector/config/OktaConfiguration.java new file mode 100644 index 000000000..f7d6959da --- /dev/null +++ b/cerberus-auth-connector-okta/src/main/java/com/nike/cerberus/auth/connector/config/OktaConfiguration.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.auth.connector.config; + +import com.nike.cerberus.auth.connector.AuthConnector; +import com.nike.cerberus.auth.connector.okta.OktaAuthConnector; +import com.nike.cerberus.auth.connector.okta.OktaConfigurationProperties; +import com.okta.authn.sdk.client.AuthenticationClient; +import com.okta.authn.sdk.client.AuthenticationClients; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; + +@Slf4j +@Configuration +@ConditionalOnProperty("cerberus.auth.user.connector.okta.enabled") +@ComponentScan({"com.nike.cerberus.auth.connector.okta"}) +public class OktaConfiguration { + + @Bean + @ConfigurationProperties("cerberus.auth.user.connector.okta") + public OktaConfigurationProperties oktaConfigurationProperties() { + return new OktaConfigurationProperties(); + } + + @Bean + AuthenticationClient authenticationClient( + OktaConfigurationProperties oktaConfigurationProperties) { + System.setProperty("okta.client.token", oktaConfigurationProperties.getApiKey()); + return AuthenticationClients.builder() + .setOrgUrl(oktaConfigurationProperties.getBaseUrl()) + .build(); + } + + @Bean + AuthConnector authConnector(OktaAuthConnector oktaAuthConnector) { + return oktaAuthConnector; + } +} diff --git a/cerberus-auth-connector-okta/src/main/java/com/nike/cerberus/auth/connector/okta/EmbeddedAuthResponseDataV1.java b/cerberus-auth-connector-okta/src/main/java/com/nike/cerberus/auth/connector/okta/EmbeddedAuthResponseDataV1.java new file mode 100644 index 000000000..2445d85e7 --- /dev/null +++ b/cerberus-auth-connector-okta/src/main/java/com/nike/cerberus/auth/connector/okta/EmbeddedAuthResponseDataV1.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.auth.connector.okta; + +import com.okta.sdk.models.factors.Factor; +import com.okta.sdk.models.users.User; +import java.util.List; + +/** POJO representing embedded data within the user authentication response. */ +public class EmbeddedAuthResponseDataV1 { + + private User user; + + private List factors; + + public User getUser() { + return user; + } + + public void setUser(User user) { + this.user = user; + } + + public List getFactors() { + return factors; + } + + public void setFactors(List factors) { + this.factors = factors; + } +} diff --git a/cerberus-auth-connector-okta/src/main/java/com/nike/cerberus/auth/connector/okta/OktaApiClientHelper.java b/cerberus-auth-connector-okta/src/main/java/com/nike/cerberus/auth/connector/okta/OktaApiClientHelper.java new file mode 100644 index 000000000..ff12c9a62 --- /dev/null +++ b/cerberus-auth-connector-okta/src/main/java/com/nike/cerberus/auth/connector/okta/OktaApiClientHelper.java @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.auth.connector.okta; + +import com.nike.backstopper.exception.ApiException; +import com.nike.cerberus.error.DefaultApiError; +import com.okta.sdk.clients.UserGroupApiClient; +import com.okta.sdk.framework.ApiClientConfiguration; +import com.okta.sdk.framework.PagedResults; +import com.okta.sdk.models.usergroups.UserGroup; +import java.io.IOException; +import java.util.LinkedList; +import java.util.List; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** Helper methods to authenticate with Okta. */ +@Component +public class OktaApiClientHelper { + + private final UserGroupApiClient userGroupApiClient; + + private final String baseUrl; + + protected OktaApiClientHelper(UserGroupApiClient userGroupApiClient, String baseUrl) { + + this.userGroupApiClient = userGroupApiClient; + this.baseUrl = baseUrl; + } + + @Autowired + public OktaApiClientHelper(OktaConfigurationProperties oktaConfigurationProperties) { + this.baseUrl = oktaConfigurationProperties.getBaseUrl(); + + final ApiClientConfiguration clientConfiguration = + new ApiClientConfiguration(baseUrl, oktaConfigurationProperties.getApiKey()); + userGroupApiClient = new UserGroupApiClient(clientConfiguration); + } + + /** + * Request to get user group data by the user's ID. + * + * @param userId User ID + * @return User groups + */ + protected List getUserGroups(final String userId) { + return getUserGroups(userId, null); + } + + /** + * Request to get user group data by the user's ID. + * + * @param userId User ID + * @param limit The page size limit, if null the default will be used + * @return User groups + */ + protected List getUserGroups(String userId, Integer limit) { + List userGroups = new LinkedList<>(); + try { + boolean hasNext; + StringBuilder urlBuilder = + new StringBuilder(baseUrl).append("/api/v1/users/").append(userId).append("/groups"); + + if (limit != null) { + urlBuilder.append("?limit=").append(limit); + } + String url = urlBuilder.toString(); + + do { + PagedResults userGroupPagedResults = + userGroupApiClient.getUserGroupsPagedResultsByUrl(url); + + userGroups.addAll(userGroupPagedResults.getResult()); + + if (!userGroupPagedResults.isLastPage()) { + hasNext = true; + url = userGroupPagedResults.getNextUrl(); + } else { + hasNext = false; + } + } while (hasNext); + } catch (IOException ioe) { + final String msg = + String.format( + "failed to get user groups for user (%s) for reason: %s", userId, ioe.getMessage()); + + throw ApiException.newBuilder() + .withApiErrors(DefaultApiError.GENERIC_BAD_REQUEST) + .withExceptionMessage(msg) + .build(); + } + return userGroups; + } +} diff --git a/cerberus-auth-connector-okta/src/main/java/com/nike/cerberus/auth/connector/okta/OktaAuthConnector.java b/cerberus-auth-connector-okta/src/main/java/com/nike/cerberus/auth/connector/okta/OktaAuthConnector.java new file mode 100644 index 000000000..c057f12c2 --- /dev/null +++ b/cerberus-auth-connector-okta/src/main/java/com/nike/cerberus/auth/connector/okta/OktaAuthConnector.java @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.auth.connector.okta; + +import com.google.common.base.Preconditions; +import com.nike.backstopper.exception.ApiException; +import com.nike.cerberus.auth.connector.AuthConnector; +import com.nike.cerberus.auth.connector.AuthData; +import com.nike.cerberus.auth.connector.AuthResponse; +import com.nike.cerberus.auth.connector.okta.statehandlers.InitialLoginStateHandler; +import com.nike.cerberus.auth.connector.okta.statehandlers.MfaStateHandler; +import com.nike.cerberus.error.DefaultApiError; +import com.okta.authn.sdk.FactorValidationException; +import com.okta.authn.sdk.client.AuthenticationClient; +import com.okta.authn.sdk.impl.resource.DefaultVerifyPassCodeFactorRequest; +import com.okta.sdk.models.usergroups.UserGroup; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** Okta version 1 API implementation of the AuthConnector interface. */ +@Component +public class OktaAuthConnector implements AuthConnector { + + private final OktaApiClientHelper oktaApiClientHelper; + + private final AuthenticationClient oktaAuthenticationClient; + + @Autowired + public OktaAuthConnector( + final OktaApiClientHelper oktaApiClientHelper, + AuthenticationClient oktaAuthenticationClient) { + + this.oktaApiClientHelper = oktaApiClientHelper; + this.oktaAuthenticationClient = oktaAuthenticationClient; + } + + /** Authenticates user using Okta Auth SDK. */ + @Override + public AuthResponse authenticate(String username, String password) { + + CompletableFuture authResponse = new CompletableFuture<>(); + InitialLoginStateHandler stateHandler = + new InitialLoginStateHandler(oktaAuthenticationClient, authResponse); + + try { + oktaAuthenticationClient.authenticate(username, password.toCharArray(), null, stateHandler); + return authResponse.get(45, TimeUnit.SECONDS); + } catch (ApiException e) { + throw e; + } catch (Exception e) { + throw ApiException.newBuilder() + .withExceptionCause(e) + .withApiErrors(DefaultApiError.LOGIN_FAILED) + .withExceptionMessage( + "Failed to login or failed to wait for Okta Auth Completable Future to complete.") + .build(); + } + } + + /** Triggers challenge for SMS or Call factors using Okta Auth SDK. */ + public AuthResponse triggerChallenge(String stateToken, String deviceId) { + + CompletableFuture authResponse = new CompletableFuture<>(); + MfaStateHandler stateHandler = new MfaStateHandler(oktaAuthenticationClient, authResponse); + + try { + oktaAuthenticationClient.challengeFactor(deviceId, stateToken, stateHandler); + return authResponse.get(45, TimeUnit.SECONDS); + } catch (ApiException e) { + throw e; + } catch (Exception e) { + throw ApiException.newBuilder() + .withExceptionCause(e) + .withApiErrors(DefaultApiError.AUTH_RESPONSE_WAIT_FAILED) + .withExceptionMessage("Failed to trigger challenge due to timeout. Please try again.") + .build(); + } + } + + /** Verifies user's MFA factor using Okta Auth SDK. */ + @Override + public AuthResponse mfaCheck(String stateToken, String deviceId, String otpToken) { + + CompletableFuture authResponse = new CompletableFuture<>(); + MfaStateHandler stateHandler = new MfaStateHandler(oktaAuthenticationClient, authResponse); + + DefaultVerifyPassCodeFactorRequest request = + oktaAuthenticationClient.instantiate(DefaultVerifyPassCodeFactorRequest.class); + request.setPassCode(otpToken); + request.setStateToken(stateToken); + + try { + oktaAuthenticationClient.verifyFactor(deviceId, request, stateHandler); + return authResponse.get(45, TimeUnit.SECONDS); + } catch (ApiException e) { + throw e; + } catch (FactorValidationException e) { + throw ApiException.newBuilder() + .withExceptionCause(e) + .withApiErrors(DefaultApiError.FACTOR_VALIDATE_FAILED) + .withExceptionMessage("Failed to validate factor.") + .build(); + } catch (Exception e) { + throw ApiException.newBuilder() + .withExceptionCause(e) + .withApiErrors(DefaultApiError.AUTH_RESPONSE_WAIT_FAILED) + .withExceptionMessage("Failed to wait for Okta Auth Completable Future to complete.") + .build(); + } + } + + /** Obtains groups user belongs to. */ + @Override + public Set getGroups(AuthData authData) { + + Preconditions.checkNotNull(authData, "auth data cannot be null."); + + final List userGroups = oktaApiClientHelper.getUserGroups(authData.getUserId()); + + final Set groups = new HashSet<>(); + if (userGroups.isEmpty()) { + return groups; + } + + userGroups.forEach(group -> groups.add(group.getProfile().getName())); + + return groups; + } +} diff --git a/cerberus-auth-connector-okta/src/main/java/com/nike/cerberus/auth/connector/okta/OktaConfigurationProperties.java b/cerberus-auth-connector-okta/src/main/java/com/nike/cerberus/auth/connector/okta/OktaConfigurationProperties.java new file mode 100644 index 000000000..d5cb4c9fe --- /dev/null +++ b/cerberus-auth-connector-okta/src/main/java/com/nike/cerberus/auth/connector/okta/OktaConfigurationProperties.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.auth.connector.okta; + +import javax.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class OktaConfigurationProperties { + @NotBlank // TODO this didn't work + private String apiKey; + @NotBlank private String baseUrl; +} diff --git a/cerberus-auth-connector-okta/src/main/java/com/nike/cerberus/auth/connector/okta/statehandlers/AbstractOktaStateHandler.java b/cerberus-auth-connector-okta/src/main/java/com/nike/cerberus/auth/connector/okta/statehandlers/AbstractOktaStateHandler.java new file mode 100644 index 000000000..8b4c05de3 --- /dev/null +++ b/cerberus-auth-connector-okta/src/main/java/com/nike/cerberus/auth/connector/okta/statehandlers/AbstractOktaStateHandler.java @@ -0,0 +1,215 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.auth.connector.okta.statehandlers; + +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.nike.backstopper.apierror.ApiErrorBase; +import com.nike.backstopper.exception.ApiException; +import com.nike.cerberus.auth.connector.AuthData; +import com.nike.cerberus.auth.connector.AuthResponse; +import com.nike.cerberus.auth.connector.AuthStatus; +import com.nike.cerberus.error.DefaultApiError; +import com.okta.authn.sdk.AuthenticationStateHandlerAdapter; +import com.okta.authn.sdk.client.AuthenticationClient; +import com.okta.authn.sdk.resource.AuthenticationResponse; +import com.okta.authn.sdk.resource.Factor; +import com.okta.sdk.resource.user.factor.FactorProvider; +import com.okta.sdk.resource.user.factor.FactorType; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.text.WordUtils; + +/** + * Abstract state handler to provide helper methods for authentication and MFA validation. Also + * handles unknown states. + */ +public abstract class AbstractOktaStateHandler extends AuthenticationStateHandlerAdapter { + + public static final String MFA_FACTOR_NOT_SETUP_STATUS = "NOT_SETUP"; + + private static final Map MFA_FACTOR_NAMES = + ImmutableMap.of( + "google-token:software:totp", "Google Authenticator", + "okta-token:software:totp", "Okta Verify TOTP", + "okta-push", "Okta Verify Push", + "okta-call", "Okta Voice Call", + "okta-sms", "Okta Text Message Code"); + + private static final Map MFA_FACTOR_TRIGGER_REQUIRED = + ImmutableMap.of( + "google-token:software:totp", false, + "okta-token:software:totp", false, + "okta-push", true, + "okta-call", true, + "okta-sms", true); + + private static final Map STATUS_ERRORS = + new ImmutableMap.Builder() + .put("UNAUTHENTICATED", "User is not authenticated. Please confirm credentials.") + .put("PASSWORD_WARN", "Password is about to expire and should be changed.") + .put("PASSWORD_EXPIRED", "Password has expired. Please update your password.") + .put( + "RECOVERY", + "Please check for a recovery token to reset your password or unlock your account.") + .put("RECOVERY_CHALLENGE", "Please verify the factor-specific recovery challenge.") + .put("PASSWORD_RESET", "Please set a new password.") + .put("LOCKED_OUT", "Your OKTA user account is locked.") + .put("MFA_ENROLL_ACTIVATE", "Please activate your factor to complete enrollment.") + .build(); + + // We currently do not support push notifications for Okta MFA verification. + private static final ImmutableSet UNSUPPORTED_OKTA_MFA_TYPES = ImmutableSet.of(FactorType.PUSH); + + public final AuthenticationClient client; + public final CompletableFuture authenticationResponseFuture; + + public AbstractOktaStateHandler( + AuthenticationClient client, CompletableFuture authenticationResponseFuture) { + this.client = client; + this.authenticationResponseFuture = authenticationResponseFuture; + } + + /** + * Combine the provider and factor type to create factor key + * + * @param factor Okta MFA factor + * @return factor key + */ + public String getFactorKey(Factor factor) { + + final String factorProvider = factor.getProvider().toString().toLowerCase(); + final String factorType = factor.getType().toString().toLowerCase(); + + return factorProvider + "-" + factorType; + } + + /** + * Print a user-friendly name for a MFA device + * + * @param factor Okta MFA factor + * @return Device name + */ + public String getDeviceName(final Factor factor) { + + Preconditions.checkArgument(factor != null, "Factor cannot be null."); + + final String factorKey = getFactorKey(factor); + + if (MFA_FACTOR_NAMES.containsKey(factorKey)) { + return MFA_FACTOR_NAMES.get(factorKey); + } + return WordUtils.capitalizeFully(factorKey); + } + + /** + * Determines whether a trigger is required for a provided MFA factor + * + * @param factor Okta MFA factor + * @return boolean trigger required + */ + public boolean isTriggerRequired(Factor factor) { + + Preconditions.checkArgument(factor != null, "Factor cannot be null."); + + final String factorKey = getFactorKey(factor); + + if (MFA_FACTOR_TRIGGER_REQUIRED.containsKey(factorKey)) { + return MFA_FACTOR_TRIGGER_REQUIRED.get(factorKey); + } + return false; + } + + /** + * Determines if a MFA factor is currently supported by Cerberus or not + * + * @param factor Okta MFA factor + * @return boolean + */ + public boolean isSupportedFactor(Factor factor) { + + final FactorType type = factor.getType(); + final FactorProvider provider = factor.getProvider(); + + return !(provider.equals(FactorProvider.OKTA) && UNSUPPORTED_OKTA_MFA_TYPES.contains(type)); + } + + /** + * Ensure the user has at least one active MFA device set up + * + * @param factors - List of user factors + */ + public void validateUserFactors(final List factors) { + + if (factors == null + || factors.isEmpty() + || factors.stream() + .allMatch( + factor -> StringUtils.equals(factor.getStatus(), MFA_FACTOR_NOT_SETUP_STATUS))) { + + throw ApiException.newBuilder() + .withApiErrors(DefaultApiError.MFA_SETUP_REQUIRED) + .withExceptionMessage("MFA is required, but user has not set up any devices in Okta.") + .build(); + } + } + + /** + * Handles authentication success. + * + * @param successResponse - Authentication response from the Completable Future + */ + @Override + public void handleSuccess(AuthenticationResponse successResponse) { + + final String userId = successResponse.getUser().getId(); + final String userLogin = successResponse.getUser().getLogin(); + + final AuthData authData = new AuthData().setUserId(userId).setUsername(userLogin); + AuthResponse authResponse = new AuthResponse().setData(authData).setStatus(AuthStatus.SUCCESS); + + authenticationResponseFuture.complete(authResponse); + } + + /** + * Handles all unknown states that are not specifically dealt with by the other state handlers and + * reports a relevant API Error for the state + * + * @param typedUnknownResponse - Authentication response from the Completable Future + */ + public void handleUnknown(AuthenticationResponse typedUnknownResponse) { + + String status = typedUnknownResponse.getStatusString(); + String message = + "MFA is required. Please confirm that you are enrolled in a supported MFA device."; + if (STATUS_ERRORS.containsKey(status)) { + message = STATUS_ERRORS.get(status); + } + + throw ApiException.newBuilder() + .withApiErrors( + new ApiErrorBase( + DefaultApiError.AUTH_FAILED.getName(), + DefaultApiError.AUTH_FAILED.getErrorCode(), + message, + DefaultApiError.AUTH_FAILED.getHttpStatusCode())) + .build(); + } +} diff --git a/cerberus-auth-connector-okta/src/main/java/com/nike/cerberus/auth/connector/okta/statehandlers/InitialLoginStateHandler.java b/cerberus-auth-connector-okta/src/main/java/com/nike/cerberus/auth/connector/okta/statehandlers/InitialLoginStateHandler.java new file mode 100644 index 000000000..6d07c996f --- /dev/null +++ b/cerberus-auth-connector-okta/src/main/java/com/nike/cerberus/auth/connector/okta/statehandlers/InitialLoginStateHandler.java @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.auth.connector.okta.statehandlers; + +import com.nike.cerberus.auth.connector.AuthData; +import com.nike.cerberus.auth.connector.AuthMfaDevice; +import com.nike.cerberus.auth.connector.AuthResponse; +import com.nike.cerberus.auth.connector.AuthStatus; +import com.okta.authn.sdk.client.AuthenticationClient; +import com.okta.authn.sdk.resource.AuthenticationResponse; +import com.okta.authn.sdk.resource.Factor; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; + +/** Initial state handler to handle relevant states during authentication. */ +public class InitialLoginStateHandler extends AbstractOktaStateHandler { + + public InitialLoginStateHandler( + AuthenticationClient client, CompletableFuture authenticationResponseFuture) { + super(client, authenticationResponseFuture); + } + + /** + * Handles MFA required state + * + * @param mfaRequiredResponse - Authentication response from the Completable Future + */ + @Override + public void handleMfaRequired(AuthenticationResponse mfaRequiredResponse) { + handleMfaResponse(mfaRequiredResponse); + } + + /** + * Handles MFA enroll state, when a user is not enrolled in any MFA factors. + * + * @param mfaEnroll - Authentication response from the Completable Future + */ + @Override + public void handleMfaEnroll(AuthenticationResponse mfaEnroll) { + handleMfaResponse(mfaEnroll); + } + + /** + * Handles MFA states by determining valid user MFA factors. + * + * @param mfaResponse - Authentication response from the Completable Future + */ + private void handleMfaResponse(AuthenticationResponse mfaResponse) { + final String userId = mfaResponse.getUser().getId(); + final String userLogin = mfaResponse.getUser().getLogin(); + + final AuthData authData = new AuthData().setUserId(userId).setUsername(userLogin); + final AuthResponse authResponse = new AuthResponse().setData(authData); + + authData.setStateToken(mfaResponse.getStateToken()); + authResponse.setStatus(AuthStatus.MFA_REQUIRED); + + final List factors = + mfaResponse.getFactors().stream() + .filter(this::isSupportedFactor) + .collect(Collectors.toList()); + + validateUserFactors(factors); + + factors.forEach( + factor -> + authData + .getDevices() + .add( + new AuthMfaDevice() + .setId(factor.getId()) + .setName(getDeviceName(factor)) + .setRequiresTrigger(isTriggerRequired(factor)))); + + authenticationResponseFuture.complete(authResponse); + } +} diff --git a/cerberus-auth-connector-okta/src/main/java/com/nike/cerberus/auth/connector/okta/statehandlers/MfaStateHandler.java b/cerberus-auth-connector-okta/src/main/java/com/nike/cerberus/auth/connector/okta/statehandlers/MfaStateHandler.java new file mode 100644 index 000000000..3a9c2608d --- /dev/null +++ b/cerberus-auth-connector-okta/src/main/java/com/nike/cerberus/auth/connector/okta/statehandlers/MfaStateHandler.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.auth.connector.okta.statehandlers; + +import com.nike.cerberus.auth.connector.AuthData; +import com.nike.cerberus.auth.connector.AuthResponse; +import com.nike.cerberus.auth.connector.AuthStatus; +import com.okta.authn.sdk.client.AuthenticationClient; +import com.okta.authn.sdk.resource.AuthenticationResponse; +import java.util.concurrent.CompletableFuture; + +/** MFA state handler to handle MFA challenge when verifying an MFA factor. */ +public class MfaStateHandler extends AbstractOktaStateHandler { + + public MfaStateHandler( + AuthenticationClient client, CompletableFuture authenticationResponseFuture) { + super(client, authenticationResponseFuture); + } + + /** + * Handles MFA Challenge, when a MFA challenge has been initiated for call or sms. + * + * @param mfaChallengeResponse - Authentication response from the Completable Future + */ + @Override + public void handleMfaChallenge(AuthenticationResponse mfaChallengeResponse) { + + final String userId = mfaChallengeResponse.getUser().getId(); + final String userLogin = mfaChallengeResponse.getUser().getLogin(); + + final AuthData authData = new AuthData().setUserId(userId).setUsername(userLogin); + AuthResponse authResponse = + new AuthResponse().setData(authData).setStatus(AuthStatus.MFA_CHALLENGE); + + authenticationResponseFuture.complete(authResponse); + } +} diff --git a/cerberus-auth-connector-okta/src/test/java/com/nike/cerberus/auth/connector/okta/AbstractOktaStateHandlerTest.java b/cerberus-auth-connector-okta/src/test/java/com/nike/cerberus/auth/connector/okta/AbstractOktaStateHandlerTest.java new file mode 100644 index 000000000..f19f96f90 --- /dev/null +++ b/cerberus-auth-connector-okta/src/test/java/com/nike/cerberus/auth/connector/okta/AbstractOktaStateHandlerTest.java @@ -0,0 +1,271 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.auth.connector.okta; + +import static groovy.util.GroovyTestCase.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.mockito.MockitoAnnotations.initMocks; + +import com.google.common.collect.Lists; +import com.nike.backstopper.exception.ApiException; +import com.nike.cerberus.auth.connector.AuthResponse; +import com.nike.cerberus.auth.connector.AuthStatus; +import com.nike.cerberus.auth.connector.okta.statehandlers.AbstractOktaStateHandler; +import com.okta.authn.sdk.client.AuthenticationClient; +import com.okta.authn.sdk.impl.resource.DefaultFactor; +import com.okta.authn.sdk.resource.AuthenticationResponse; +import com.okta.authn.sdk.resource.User; +import com.okta.sdk.resource.user.factor.FactorProvider; +import com.okta.sdk.resource.user.factor.FactorType; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import junit.framework.TestCase; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; + +/** Tests the AbstractOktaStateHandler class */ +public class AbstractOktaStateHandlerTest { + + // class under test + private AbstractOktaStateHandler abstractOktaStateHandler; + + // Dependencies + @Mock private AuthenticationClient client; + private CompletableFuture authenticationResponseFuture; + + @Before + public void setup() { + + initMocks(this); + + authenticationResponseFuture = new CompletableFuture<>(); + + // create test object + this.abstractOktaStateHandler = + new AbstractOktaStateHandler(client, authenticationResponseFuture) {}; + } + + ///////////////////////// + // Test Methods + ///////////////////////// + + @Test + public void getFactorKey() { + + DefaultFactor factor = mock(DefaultFactor.class); + when(factor.getType()).thenReturn(FactorType.PUSH); + when(factor.getProvider()).thenReturn(FactorProvider.OKTA); + + String expected = "okta-push"; + String actual = abstractOktaStateHandler.getFactorKey(factor); + + assertEquals(expected, actual); + } + + @Test + public void getDeviceNameGoogleTotp() { + + FactorProvider provider = FactorProvider.GOOGLE; + FactorType type = FactorType.TOKEN_SOFTWARE_TOTP; + + DefaultFactor factor = mock(DefaultFactor.class); + when(factor.getType()).thenReturn(type); + when(factor.getProvider()).thenReturn(provider); + + String result = this.abstractOktaStateHandler.getDeviceName(factor); + + assertEquals("Google Authenticator", result); + } + + @Test + public void getDeviceNameOktaTotp() { + + FactorProvider provider = FactorProvider.OKTA; + FactorType type = FactorType.TOKEN_SOFTWARE_TOTP; + + DefaultFactor factor = mock(DefaultFactor.class); + when(factor.getType()).thenReturn(type); + when(factor.getProvider()).thenReturn(provider); + + String result = this.abstractOktaStateHandler.getDeviceName(factor); + + assertEquals("Okta Verify TOTP", result); + } + + @Test + public void getDeviceNameOktaPush() { + + FactorProvider provider = FactorProvider.OKTA; + FactorType type = FactorType.PUSH; + + DefaultFactor factor = mock(DefaultFactor.class); + when(factor.getType()).thenReturn(type); + when(factor.getProvider()).thenReturn(provider); + + String result = this.abstractOktaStateHandler.getDeviceName(factor); + + assertEquals("Okta Verify Push", result); + } + + @Test + public void getDeviceNameOktaCall() { + + FactorProvider provider = FactorProvider.OKTA; + FactorType type = FactorType.CALL; + + DefaultFactor factor = mock(DefaultFactor.class); + when(factor.getType()).thenReturn(type); + when(factor.getProvider()).thenReturn(provider); + + String result = this.abstractOktaStateHandler.getDeviceName(factor); + + assertEquals("Okta Voice Call", result); + } + + @Test + public void getDeviceNameOktaSms() { + + FactorProvider provider = FactorProvider.OKTA; + FactorType type = FactorType.SMS; + + DefaultFactor factor = mock(DefaultFactor.class); + when(factor.getType()).thenReturn(type); + when(factor.getProvider()).thenReturn(provider); + + String result = this.abstractOktaStateHandler.getDeviceName(factor); + + assertEquals("Okta Text Message Code", result); + } + + @Test(expected = IllegalArgumentException.class) + public void getDeviceNameFailsNullFactor() { + + this.abstractOktaStateHandler.getDeviceName(null); + } + + @Test + public void isSupportedFactorFalse() { + + DefaultFactor factor = mock(DefaultFactor.class); + when(factor.getType()).thenReturn(FactorType.PUSH); + when(factor.getProvider()).thenReturn(FactorProvider.OKTA); + + boolean expected = false; + boolean actual = abstractOktaStateHandler.isSupportedFactor(factor); + + TestCase.assertEquals(expected, actual); + } + + @Test + public void isSupportedFactorTrue() { + + DefaultFactor factor = mock(DefaultFactor.class); + when(factor.getType()).thenReturn(FactorType.TOKEN_SOFTWARE_TOTP); + when(factor.getProvider()).thenReturn(FactorProvider.OKTA); + + boolean expected = true; + boolean actual = abstractOktaStateHandler.isSupportedFactor(factor); + + TestCase.assertEquals(expected, actual); + } + + @Test + public void validateUserFactorsSuccess() { + + DefaultFactor factor1 = mock(DefaultFactor.class); + when(factor1.getStatus()).thenReturn(AbstractOktaStateHandler.MFA_FACTOR_NOT_SETUP_STATUS); + DefaultFactor factor2 = mock(DefaultFactor.class); + + this.abstractOktaStateHandler.validateUserFactors(Lists.newArrayList(factor1, factor2)); + } + + @Test(expected = ApiException.class) + public void validateUserFactorsFailsNull() { + + this.abstractOktaStateHandler.validateUserFactors(null); + } + + @Test(expected = ApiException.class) + public void validateUserFactorsFailsEmpty() { + + this.abstractOktaStateHandler.validateUserFactors(Lists.newArrayList()); + } + + @Test(expected = ApiException.class) + public void validateUserFactorsFailsAllFactorsNotSetUp() { + + String status = AbstractOktaStateHandler.MFA_FACTOR_NOT_SETUP_STATUS; + + DefaultFactor factor1 = mock(DefaultFactor.class); + when(factor1.getStatus()).thenReturn(status); + + DefaultFactor factor2 = mock(DefaultFactor.class); + when(factor2.getStatus()).thenReturn(status); + + this.abstractOktaStateHandler.validateUserFactors(Lists.newArrayList(factor1, factor2)); + } + + @Test + public void handleSuccess() throws Exception { + + String email = "email"; + String id = "id"; + AuthStatus status = AuthStatus.SUCCESS; + + AuthenticationResponse expectedResponse = mock(AuthenticationResponse.class); + + User user = mock(User.class); + when(user.getId()).thenReturn(id); + when(user.getLogin()).thenReturn(email); + when(expectedResponse.getUser()).thenReturn(user); + + // do the call + abstractOktaStateHandler.handleSuccess(expectedResponse); + + AuthResponse actualResponse = authenticationResponseFuture.get(1, TimeUnit.SECONDS); + + // verify results + Assert.assertEquals(id, actualResponse.getData().getUserId()); + Assert.assertEquals(email, actualResponse.getData().getUsername()); + Assert.assertEquals(status, actualResponse.getStatus()); + } + + @Test(expected = ApiException.class) + public void handleUnknownLockout() { + + String status = "LOCKED_OUT"; + + AuthenticationResponse unknownResponse = mock(AuthenticationResponse.class); + when(unknownResponse.getStatusString()).thenReturn(status); + + abstractOktaStateHandler.handleUnknown(unknownResponse); + } + + @Test(expected = ApiException.class) + public void handleUnknownPasswordExpired() { + + String status = "PASSWORD_EXPIRED"; + + AuthenticationResponse unknownResponse = mock(AuthenticationResponse.class); + when(unknownResponse.getStatusString()).thenReturn(status); + + abstractOktaStateHandler.handleUnknown(unknownResponse); + } +} diff --git a/cerberus-auth-connector-okta/src/test/java/com/nike/cerberus/auth/connector/okta/InitialLoginStateHandlerTest.java b/cerberus-auth-connector-okta/src/test/java/com/nike/cerberus/auth/connector/okta/InitialLoginStateHandlerTest.java new file mode 100644 index 000000000..529eb4562 --- /dev/null +++ b/cerberus-auth-connector-okta/src/test/java/com/nike/cerberus/auth/connector/okta/InitialLoginStateHandlerTest.java @@ -0,0 +1,203 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.auth.connector.okta; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.*; +import static org.mockito.MockitoAnnotations.initMocks; + +import com.google.common.collect.Lists; +import com.nike.backstopper.exception.ApiException; +import com.nike.cerberus.auth.connector.AuthResponse; +import com.nike.cerberus.auth.connector.AuthStatus; +import com.nike.cerberus.auth.connector.okta.statehandlers.InitialLoginStateHandler; +import com.okta.authn.sdk.client.AuthenticationClient; +import com.okta.authn.sdk.impl.resource.DefaultFactor; +import com.okta.authn.sdk.resource.AuthenticationResponse; +import com.okta.authn.sdk.resource.User; +import com.okta.sdk.resource.user.factor.FactorProvider; +import com.okta.sdk.resource.user.factor.FactorType; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; + +public class InitialLoginStateHandlerTest { + + // class under test + private InitialLoginStateHandler initialLoginStateHandler; + + // Dependencies + @Mock private AuthenticationClient client; + private CompletableFuture authenticationResponseFuture; + + @Before + public void setup() { + + initMocks(this); + + authenticationResponseFuture = new CompletableFuture<>(); + + // create test object + this.initialLoginStateHandler = + new InitialLoginStateHandler(client, authenticationResponseFuture) {}; + } + + ///////////////////////// + // Test Methods + ///////////////////////// + + @Test + public void handleMfaRequired() throws Exception { + + String email = "email"; + String id = "id"; + AuthStatus expectedStatus = AuthStatus.MFA_REQUIRED; + + FactorProvider provider = FactorProvider.OKTA; + FactorType type = FactorType.TOKEN_SOFTWARE_TOTP; + String deviceId = "device id"; + String status = "status"; + + AuthenticationResponse expectedResponse = mock(AuthenticationResponse.class); + + User user = mock(User.class); + when(user.getId()).thenReturn(id); + when(user.getLogin()).thenReturn(email); + when(expectedResponse.getUser()).thenReturn(user); + + DefaultFactor factor = mock(DefaultFactor.class); + + when(factor.getType()).thenReturn(type); + when(factor.getProvider()).thenReturn(provider); + when(factor.getStatus()).thenReturn(status); + when(factor.getId()).thenReturn(deviceId); + when(expectedResponse.getFactors()).thenReturn(Lists.newArrayList(factor)); + + // do the call + initialLoginStateHandler.handleMfaRequired(expectedResponse); + + AuthResponse actualResponse = authenticationResponseFuture.get(1, TimeUnit.SECONDS); + + // verify results + assertEquals(id, actualResponse.getData().getUserId()); + assertEquals(email, actualResponse.getData().getUsername()); + assertEquals(expectedStatus, actualResponse.getStatus()); + } + + @Test(expected = ApiException.class) + public void handleMfaRequiredFailNoSupportedDevicesEnrolled() throws Exception { + + String email = "email"; + String id = "id"; + AuthStatus expectedStatus = AuthStatus.MFA_REQUIRED; + + FactorProvider provider = FactorProvider.OKTA; + FactorType type = FactorType.PUSH; + String deviceId = "device id"; + String status = "status"; + + AuthenticationResponse expectedResponse = mock(AuthenticationResponse.class); + + User user = mock(User.class); + when(user.getId()).thenReturn(id); + when(user.getLogin()).thenReturn(email); + when(expectedResponse.getUser()).thenReturn(user); + + DefaultFactor factor = mock(DefaultFactor.class); + + when(factor.getType()).thenReturn(type); + when(factor.getProvider()).thenReturn(provider); + when(factor.getStatus()).thenReturn(status); + when(factor.getId()).thenReturn(deviceId); + when(expectedResponse.getFactors()).thenReturn(Lists.newArrayList(factor)); + + // do the call + initialLoginStateHandler.handleMfaRequired(expectedResponse); + + AuthResponse actualResponse = authenticationResponseFuture.get(1, TimeUnit.SECONDS); + + // verify results + assertEquals(id, actualResponse.getData().getUserId()); + assertEquals(email, actualResponse.getData().getUsername()); + assertEquals(expectedStatus, actualResponse.getStatus()); + } + + @Test + public void handleMfaEnroll() throws Exception { + + String email = "email"; + String id = "id"; + AuthStatus expectedStatus = AuthStatus.MFA_REQUIRED; + + FactorProvider provider = FactorProvider.OKTA; + FactorType type = FactorType.TOKEN_SOFTWARE_TOTP; + String deviceId = "device id"; + String status = "status"; + + AuthenticationResponse expectedResponse = mock(AuthenticationResponse.class); + + User user = mock(User.class); + when(user.getId()).thenReturn(id); + when(user.getLogin()).thenReturn(email); + when(expectedResponse.getUser()).thenReturn(user); + + DefaultFactor factor = mock(DefaultFactor.class); + + when(factor.getType()).thenReturn(type); + when(factor.getProvider()).thenReturn(provider); + when(factor.getStatus()).thenReturn(status); + when(factor.getId()).thenReturn(deviceId); + when(expectedResponse.getFactors()).thenReturn(Lists.newArrayList(factor)); + + // do the call + initialLoginStateHandler.handleMfaEnroll(expectedResponse); + + AuthResponse actualResponse = authenticationResponseFuture.get(1, TimeUnit.SECONDS); + + // verify results + assertEquals(id, actualResponse.getData().getUserId()); + assertEquals(email, actualResponse.getData().getUsername()); + assertEquals(expectedStatus, actualResponse.getStatus()); + } + + @Test(expected = ApiException.class) + public void handleMfaEnrollFails() throws Exception { + + String email = "email"; + String id = "id"; + AuthStatus expectedStatus = AuthStatus.MFA_REQUIRED; + + AuthenticationResponse expectedResponse = mock(AuthenticationResponse.class); + + User user = mock(User.class); + when(user.getId()).thenReturn(id); + when(user.getLogin()).thenReturn(email); + when(expectedResponse.getUser()).thenReturn(user); + + // do the call + initialLoginStateHandler.handleMfaEnroll(expectedResponse); + + AuthResponse actualResponse = authenticationResponseFuture.get(1, TimeUnit.SECONDS); + + // verify results + assertEquals(id, actualResponse.getData().getUserId()); + assertEquals(email, actualResponse.getData().getUsername()); + assertEquals(expectedStatus, actualResponse.getStatus()); + } +} diff --git a/cerberus-auth-connector-okta/src/test/java/com/nike/cerberus/auth/connector/okta/MfaStateHandlerTest.java b/cerberus-auth-connector-okta/src/test/java/com/nike/cerberus/auth/connector/okta/MfaStateHandlerTest.java new file mode 100644 index 000000000..081c901a1 --- /dev/null +++ b/cerberus-auth-connector-okta/src/test/java/com/nike/cerberus/auth/connector/okta/MfaStateHandlerTest.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.auth.connector.okta; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.mockito.MockitoAnnotations.initMocks; + +import com.nike.cerberus.auth.connector.AuthResponse; +import com.nike.cerberus.auth.connector.AuthStatus; +import com.nike.cerberus.auth.connector.okta.statehandlers.MfaStateHandler; +import com.okta.authn.sdk.client.AuthenticationClient; +import com.okta.authn.sdk.resource.AuthenticationResponse; +import com.okta.authn.sdk.resource.User; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; + +/** Tests the MfaOktaStateHandler class */ +public class MfaStateHandlerTest { + + // class under test + private MfaStateHandler mfaStateHandler; + + // Dependencies + @Mock private AuthenticationClient client; + private CompletableFuture authenticationResponseFuture; + + @Before + public void setup() { + + initMocks(this); + + authenticationResponseFuture = new CompletableFuture<>(); + + // create test object + this.mfaStateHandler = new MfaStateHandler(client, authenticationResponseFuture) {}; + } + + ///////////////////////// + // Test Methods + ///////////////////////// + + @Test + public void handleMfaChallenge() throws Exception { + + String email = "email"; + String id = "id"; + AuthStatus status = AuthStatus.MFA_CHALLENGE; + + AuthenticationResponse expectedResponse = mock(AuthenticationResponse.class); + + User user = mock(User.class); + when(user.getId()).thenReturn(id); + when(user.getLogin()).thenReturn(email); + when(expectedResponse.getUser()).thenReturn(user); + + // do the call + mfaStateHandler.handleMfaChallenge(expectedResponse); + + AuthResponse actualResponse = authenticationResponseFuture.get(1, TimeUnit.SECONDS); + + // verify results + assertEquals(id, actualResponse.getData().getUserId()); + assertEquals(email, actualResponse.getData().getUsername()); + assertEquals(status, actualResponse.getStatus()); + } +} diff --git a/cerberus-auth-connector-okta/src/test/java/com/nike/cerberus/auth/connector/okta/OktaApiClientHelperTest.java b/cerberus-auth-connector-okta/src/test/java/com/nike/cerberus/auth/connector/okta/OktaApiClientHelperTest.java new file mode 100644 index 000000000..2612779b5 --- /dev/null +++ b/cerberus-auth-connector-okta/src/test/java/com/nike/cerberus/auth/connector/okta/OktaApiClientHelperTest.java @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.auth.connector.okta; + +import static org.junit.Assert.assertTrue; +import static org.mockito.Matchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.mockito.MockitoAnnotations.initMocks; + +import com.google.common.collect.Lists; +import com.nike.backstopper.exception.ApiException; +import com.okta.sdk.clients.UserGroupApiClient; +import com.okta.sdk.framework.PagedResults; +import com.okta.sdk.models.usergroups.UserGroup; +import java.io.IOException; +import java.util.List; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; + +/** Tests the OktaApiClientHelper class */ +public class OktaApiClientHelperTest { + + // class under test + private OktaApiClientHelper oktaApiClientHelper; + + // dependencies + @Mock private UserGroupApiClient userGroupApiClient; + + @Before + public void setup() { + + initMocks(this); + + // create test object + this.oktaApiClientHelper = new OktaApiClientHelper(userGroupApiClient, ""); + } + + ///////////////////////// + // Test Methods + ///////////////////////// + + @Test + public void getUserGroupsHappy() throws Exception { + + String id = "id"; + UserGroup group = mock(UserGroup.class); + PagedResults res = mock(PagedResults.class); + when(res.getResult()).thenReturn(Lists.newArrayList(group)); + when(res.isLastPage()).thenReturn(true); + when(userGroupApiClient.getUserGroupsPagedResultsByUrl(anyString())).thenReturn(res); + + // do the call + List result = this.oktaApiClientHelper.getUserGroups(id); + + // verify results + assertTrue(result.contains(group)); + } + + @Test(expected = ApiException.class) + public void getUserGroupsFails() throws Exception { + + when(userGroupApiClient.getUserGroupsPagedResultsByUrl(anyString())) + .thenThrow(IOException.class); + + // do the call + this.oktaApiClientHelper.getUserGroups("id"); + } +} diff --git a/cerberus-auth-connector-okta/src/test/java/com/nike/cerberus/auth/connector/okta/OktaAuthConnectorTest.java b/cerberus-auth-connector-okta/src/test/java/com/nike/cerberus/auth/connector/okta/OktaAuthConnectorTest.java new file mode 100644 index 000000000..93c4cfe87 --- /dev/null +++ b/cerberus-auth-connector-okta/src/test/java/com/nike/cerberus/auth/connector/okta/OktaAuthConnectorTest.java @@ -0,0 +1,278 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.auth.connector.okta; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.*; +import static org.mockito.MockitoAnnotations.initMocks; + +import com.google.common.collect.Lists; +import com.nike.backstopper.exception.ApiException; +import com.nike.cerberus.auth.connector.AuthData; +import com.nike.cerberus.auth.connector.AuthResponse; +import com.nike.cerberus.auth.connector.AuthStatus; +import com.nike.cerberus.auth.connector.okta.statehandlers.InitialLoginStateHandler; +import com.nike.cerberus.auth.connector.okta.statehandlers.MfaStateHandler; +import com.okta.authn.sdk.client.AuthenticationClient; +import com.okta.authn.sdk.impl.resource.DefaultVerifyPassCodeFactorRequest; +import com.okta.sdk.models.usergroups.UserGroup; +import com.okta.sdk.models.usergroups.UserGroupProfile; +import java.util.Set; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; + +/** Tests the OktaAuthConnector class */ +public class OktaAuthConnectorTest { + + // class under test + private OktaAuthConnector oktaAuthConnector; + + // dependencies + @Mock private OktaApiClientHelper oktaApiClientHelper; + + @Mock private AuthenticationClient client; + + @Before + public void setup() { + + initMocks(this); + // create test object + oktaAuthConnector = new OktaAuthConnector(oktaApiClientHelper, client); + + reset(oktaApiClientHelper); + } + + ///////////////////////// + // Test Methods + ///////////////////////// + + @Test + public void authenticateSuccess() throws Exception { + + String username = "username"; + String password = "password"; + + AuthResponse expectedResponse = mock(AuthResponse.class); + when(expectedResponse.getStatus()).thenReturn(AuthStatus.SUCCESS); + + doAnswer( + invocation -> { + InitialLoginStateHandler stateHandler = + (InitialLoginStateHandler) invocation.getArguments()[3]; + stateHandler.authenticationResponseFuture.complete(expectedResponse); + return null; + }) + .when(client) + .authenticate(any(), any(), any(), any()); + + // do the call + AuthResponse actualResponse = this.oktaAuthConnector.authenticate(username, password); + + // verify results + assertEquals(expectedResponse, actualResponse); + } + + @Test(expected = ApiException.class) + public void authenticateFails() throws Exception { + + String username = "username"; + String password = "password"; + + AuthResponse expectedResponse = mock(AuthResponse.class); + when(expectedResponse.getStatus()).thenReturn(AuthStatus.MFA_REQUIRED); + + doAnswer( + invocation -> { + InitialLoginStateHandler stateHandler = + (InitialLoginStateHandler) invocation.getArguments()[3]; + stateHandler.authenticationResponseFuture.cancel(true); + return null; + }) + .when(client) + .authenticate(any(), any(), any(), any()); + + // do the call + AuthResponse actualResponse = this.oktaAuthConnector.authenticate(username, password); + + // verify results + assertEquals(expectedResponse, actualResponse); + } + + @Test + public void triggerChallengeSuccess() throws Exception { + + String stateToken = "state token"; + String deviceId = "device id"; + + AuthResponse expectedResponse = mock(AuthResponse.class); + + AuthData expectedData = mock(AuthData.class); + when(expectedData.getStateToken()).thenReturn(stateToken); + when(expectedResponse.getData()).thenReturn(expectedData); + + doAnswer( + invocation -> { + MfaStateHandler stateHandler = (MfaStateHandler) invocation.getArguments()[2]; + stateHandler.authenticationResponseFuture.complete(expectedResponse); + return null; + }) + .when(client) + .challengeFactor(any(), any(), any()); + + // do the call + AuthResponse actualResponse = this.oktaAuthConnector.triggerChallenge(stateToken, deviceId); + + // verify results + assertEquals(expectedResponse, actualResponse); + assertEquals( + expectedResponse.getData().getStateToken(), actualResponse.getData().getStateToken()); + } + + @Test(expected = ApiException.class) + public void triggerChallengeFails() throws Exception { + + String stateToken = "state token"; + String deviceId = "device id"; + + AuthResponse expectedResponse = mock(AuthResponse.class); + + AuthData expectedData = mock(AuthData.class); + when(expectedData.getStateToken()).thenReturn(stateToken); + when(expectedResponse.getData()).thenReturn(expectedData); + + doAnswer( + invocation -> { + MfaStateHandler stateHandler = (MfaStateHandler) invocation.getArguments()[2]; + stateHandler.authenticationResponseFuture.cancel(true); + return null; + }) + .when(client) + .challengeFactor(any(), any(), any()); + + // do the call + AuthResponse actualResponse = this.oktaAuthConnector.triggerChallenge(stateToken, deviceId); + + // verify results + assertEquals(expectedResponse, actualResponse); + assertEquals( + expectedResponse.getData().getStateToken(), actualResponse.getData().getStateToken()); + } + + @Test + public void mfaCheckSuccess() throws Exception { + + String stateToken = "state token"; + String deviceId = "device id"; + String otpToken = "otp token"; + + AuthResponse expectedResponse = mock(AuthResponse.class); + when(expectedResponse.getStatus()).thenReturn(AuthStatus.SUCCESS); + + DefaultVerifyPassCodeFactorRequest request = mock(DefaultVerifyPassCodeFactorRequest.class); + + doAnswer( + invocation -> { + request.setPassCode(stateToken); + request.setStateToken(otpToken); + return request; + }) + .when(client) + .instantiate(DefaultVerifyPassCodeFactorRequest.class); + doAnswer( + invocation -> { + MfaStateHandler stateHandler = (MfaStateHandler) invocation.getArguments()[2]; + stateHandler.authenticationResponseFuture.complete(expectedResponse); + return null; + }) + .when(client) + .verifyFactor(any(), any(), any()); + + // do the call + AuthResponse actualResponse = this.oktaAuthConnector.mfaCheck(stateToken, deviceId, otpToken); + + // verify results + assertEquals(expectedResponse, actualResponse); + } + + @Test(expected = ApiException.class) + public void mfaCheckFails() throws Exception { + + String stateToken = "state token"; + String deviceId = "device id"; + String otpToken = "otp token"; + + AuthResponse expectedResponse = mock(AuthResponse.class); + when(expectedResponse.getStatus()).thenReturn(AuthStatus.SUCCESS); + + DefaultVerifyPassCodeFactorRequest request = mock(DefaultVerifyPassCodeFactorRequest.class); + + doAnswer( + invocation -> { + request.setPassCode(stateToken); + request.setStateToken(otpToken); + return request; + }) + .when(client) + .instantiate(DefaultVerifyPassCodeFactorRequest.class); + doAnswer( + invocation -> { + MfaStateHandler stateHandler = (MfaStateHandler) invocation.getArguments()[2]; + stateHandler.authenticationResponseFuture.cancel(true); + return null; + }) + .when(client) + .verifyFactor(any(), any(), any()); + + // do the call + AuthResponse actualResponse = this.oktaAuthConnector.mfaCheck(stateToken, deviceId, otpToken); + + // verify results + assertEquals(expectedResponse, actualResponse); + } + + @Test + public void getGroupsHappy() { + + String id = "id"; + AuthData authData = mock(AuthData.class); + when(authData.getUserId()).thenReturn(id); + + String name1 = "name 1"; + UserGroupProfile profile1 = mock(UserGroupProfile.class); + UserGroup group1 = mock(UserGroup.class); + when(profile1.getName()).thenReturn(name1); + when(group1.getProfile()).thenReturn(profile1); + + String name2 = "name 2"; + UserGroupProfile profile2 = mock(UserGroupProfile.class); + UserGroup group2 = mock(UserGroup.class); + when(profile2.getName()).thenReturn(name2); + when(group2.getProfile()).thenReturn(profile2); + + when(oktaApiClientHelper.getUserGroups(id)).thenReturn(Lists.newArrayList(group1, group2)); + + // do the call + Set result = this.oktaAuthConnector.getGroups(authData); + + // verify results + assertTrue(result.contains(name1)); + assertTrue(result.contains(name2)); + } +} diff --git a/cerberus-auth-connector-onelogin/cerberus-auth-connector-onelogin.gradle b/cerberus-auth-connector-onelogin/cerberus-auth-connector-onelogin.gradle new file mode 100644 index 000000000..c9ee5c2e6 --- /dev/null +++ b/cerberus-auth-connector-onelogin/cerberus-auth-connector-onelogin.gradle @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +dependencies { + implementation project(":cerberus-core") + implementation project(":cerberus-domain") + implementation group: 'com.google.guava', name: 'guava', version: '28.1-jre' +} diff --git a/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/config/OneLoginConfiguration.java b/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/config/OneLoginConfiguration.java new file mode 100644 index 000000000..c020da8c6 --- /dev/null +++ b/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/config/OneLoginConfiguration.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.auth.connector.config; + +import com.nike.cerberus.auth.connector.onelogin.OneLoginConfigurationProperties; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; + +@Slf4j +@Configuration +@ConditionalOnProperty("cerberus.auth.user.connector.oneLogin.enabled") +@ComponentScan({"com.nike.cerberus.auth.connector.onelogin"}) +public class OneLoginConfiguration { + + @Bean + @ConfigurationProperties("cerberus.auth.user.connector.one-login") + public OneLoginConfigurationProperties oneLoginConfigurationProperties() { + return new OneLoginConfigurationProperties(); + } +} diff --git a/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/onelogin/CreateSessionLoginTokenRequest.java b/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/onelogin/CreateSessionLoginTokenRequest.java new file mode 100644 index 000000000..7be411d26 --- /dev/null +++ b/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/onelogin/CreateSessionLoginTokenRequest.java @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.auth.connector.onelogin; + +/** POJO representing a create session login token request. */ +class CreateSessionLoginTokenRequest { + + private String usernameOrEmail; + + private String password; + + private String subdomain; + + public String getUsernameOrEmail() { + return usernameOrEmail; + } + + public CreateSessionLoginTokenRequest setUsernameOrEmail(String usernameOrEmail) { + this.usernameOrEmail = usernameOrEmail; + return this; + } + + public String getPassword() { + return password; + } + + public CreateSessionLoginTokenRequest setPassword(String password) { + this.password = password; + return this; + } + + public String getSubdomain() { + return subdomain; + } + + public CreateSessionLoginTokenRequest setSubdomain(String subdomain) { + this.subdomain = subdomain; + return this; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + CreateSessionLoginTokenRequest that = (CreateSessionLoginTokenRequest) o; + + if (usernameOrEmail != null + ? !usernameOrEmail.equals(that.usernameOrEmail) + : that.usernameOrEmail != null) return false; + if (password != null ? !password.equals(that.password) : that.password != null) return false; + return subdomain != null ? subdomain.equals(that.subdomain) : that.subdomain == null; + } + + @Override + public int hashCode() { + int result = usernameOrEmail != null ? usernameOrEmail.hashCode() : 0; + result = 31 * result + (password != null ? password.hashCode() : 0); + result = 31 * result + (subdomain != null ? subdomain.hashCode() : 0); + return result; + } +} diff --git a/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/onelogin/CreateSessionLoginTokenResponse.java b/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/onelogin/CreateSessionLoginTokenResponse.java new file mode 100644 index 000000000..45397b77a --- /dev/null +++ b/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/onelogin/CreateSessionLoginTokenResponse.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.auth.connector.onelogin; + +import java.util.List; + +/** POJO representing a create session login token response. */ +class CreateSessionLoginTokenResponse { + + private ResponseStatus status; + + private List data; + + public ResponseStatus getStatus() { + return status; + } + + public CreateSessionLoginTokenResponse setStatus(ResponseStatus status) { + this.status = status; + return this; + } + + public List getData() { + return data; + } + + public CreateSessionLoginTokenResponse setData(List data) { + this.data = data; + return this; + } +} diff --git a/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/onelogin/GenerateTokenRequest.java b/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/onelogin/GenerateTokenRequest.java new file mode 100644 index 000000000..75a66bece --- /dev/null +++ b/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/onelogin/GenerateTokenRequest.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.auth.connector.onelogin; + +/** POJO representing a generate token request. */ +class GenerateTokenRequest { + + private String grantType = "client_credentials"; + + public String getGrantType() { + return grantType; + } + + public GenerateTokenRequest setGrantType(String grantType) { + this.grantType = grantType; + return this; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + GenerateTokenRequest that = (GenerateTokenRequest) o; + + return grantType != null ? grantType.equals(that.grantType) : that.grantType == null; + } + + @Override + public int hashCode() { + return grantType != null ? grantType.hashCode() : 0; + } +} diff --git a/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/onelogin/GenerateTokenResponse.java b/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/onelogin/GenerateTokenResponse.java new file mode 100644 index 000000000..1c5990ad9 --- /dev/null +++ b/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/onelogin/GenerateTokenResponse.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.auth.connector.onelogin; + +import java.util.LinkedList; +import java.util.List; + +/** POJO representing a generate token response. */ +class GenerateTokenResponse { + + private ResponseStatus status; + + private List data = new LinkedList<>(); + + public ResponseStatus getStatus() { + return status; + } + + public GenerateTokenResponse setStatus(ResponseStatus status) { + this.status = status; + return this; + } + + public List getData() { + return data; + } + + public GenerateTokenResponse setData(List data) { + this.data = data; + return this; + } +} diff --git a/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/onelogin/GenerateTokenResponseData.java b/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/onelogin/GenerateTokenResponseData.java new file mode 100644 index 000000000..480256665 --- /dev/null +++ b/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/onelogin/GenerateTokenResponseData.java @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.auth.connector.onelogin; + +import java.time.OffsetDateTime; + +/** POJO representing the payload of a generate token response. */ +class GenerateTokenResponseData { + + private String accessToken; + + private OffsetDateTime createdAt; + + private int expiresIn; + + private String refreshToken; + + private String tokenType; + + private long accountId; + + public String getAccessToken() { + return accessToken; + } + + public GenerateTokenResponseData setAccessToken(String accessToken) { + this.accessToken = accessToken; + return this; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public GenerateTokenResponseData setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + return this; + } + + public int getExpiresIn() { + return expiresIn; + } + + public GenerateTokenResponseData setExpiresIn(int expiresIn) { + this.expiresIn = expiresIn; + return this; + } + + public String getRefreshToken() { + return refreshToken; + } + + public GenerateTokenResponseData setRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + return this; + } + + public String getTokenType() { + return tokenType; + } + + public GenerateTokenResponseData setTokenType(String tokenType) { + this.tokenType = tokenType; + return this; + } + + public long getAccountId() { + return accountId; + } + + public GenerateTokenResponseData setAccountId(long accountId) { + this.accountId = accountId; + return this; + } +} diff --git a/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/onelogin/GetUserResponse.java b/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/onelogin/GetUserResponse.java new file mode 100644 index 000000000..b8f7fa2a2 --- /dev/null +++ b/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/onelogin/GetUserResponse.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.auth.connector.onelogin; + +import java.util.List; + +/** POJO representing the get user response. */ +class GetUserResponse { + + private ResponseStatus status; + + private List data; + + public ResponseStatus getStatus() { + return status; + } + + public GetUserResponse setStatus(ResponseStatus status) { + this.status = status; + return this; + } + + public List getData() { + return data; + } + + public GetUserResponse setData(List data) { + this.data = data; + return this; + } +} diff --git a/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/onelogin/MfaDevice.java b/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/onelogin/MfaDevice.java new file mode 100644 index 000000000..3eb54da57 --- /dev/null +++ b/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/onelogin/MfaDevice.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.auth.connector.onelogin; + +/** POJO representing a MFA device. */ +class MfaDevice { + + private String deviceType; + + private long deviceId; + + public String getDeviceType() { + return deviceType; + } + + public MfaDevice setDeviceType(String deviceType) { + this.deviceType = deviceType; + return this; + } + + public long getDeviceId() { + return deviceId; + } + + public MfaDevice setDeviceId(long deviceId) { + this.deviceId = deviceId; + return this; + } +} diff --git a/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/onelogin/OneLoginAuthConnector.java b/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/onelogin/OneLoginAuthConnector.java new file mode 100644 index 000000000..d163e1e5a --- /dev/null +++ b/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/onelogin/OneLoginAuthConnector.java @@ -0,0 +1,269 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.auth.connector.onelogin; + +import com.google.common.base.Splitter; +import com.google.common.collect.Iterables; +import com.nike.backstopper.apierror.ApiError; +import com.nike.backstopper.exception.ApiException; +import com.nike.cerberus.auth.connector.*; +import com.nike.cerberus.error.DefaultApiError; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import org.apache.commons.lang3.StringEscapeUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** OneLogin version 1 API implementation of the AuthConnector interface. */ +@Component +public class OneLoginAuthConnector implements AuthConnector { + + private final OneLoginClient oneLoginClient; + + @Autowired + public OneLoginAuthConnector(final OneLoginClient oneLoginClient) { + this.oneLoginClient = oneLoginClient; + } + + @Override + public AuthResponse authenticate(String username, String password) { + final SessionLoginTokenData sessionLoginToken = createSessionLoginToken(username, password); + final AuthData authData = new AuthData(); + final AuthResponse authResponse = new AuthResponse().setData(authData); + + if (StringUtils.isNotBlank(sessionLoginToken.getStateToken())) { + authResponse.setStatus(AuthStatus.MFA_REQUIRED); + authData.setStateToken(sessionLoginToken.getStateToken()); + + sessionLoginToken + .getDevices() + .forEach( + d -> + authData + .getDevices() + .add( + new AuthMfaDevice() + .setId(String.valueOf(d.getDeviceId())) + .setName(d.getDeviceType()))); + } else { + authResponse.setStatus(AuthStatus.SUCCESS); + } + + authData.setUserId(String.valueOf(sessionLoginToken.getUser().getId())); + authData.setUsername(sessionLoginToken.getUser().getUsername()); + + return authResponse; + } + + @Override + public AuthResponse triggerChallenge(String stateToken, String deviceId) { + + // TODO Convert to Default API Error once bug in Backstopper that doesn't allow 501 status codes + // is fixed. + throw ApiException.newBuilder() + .withApiErrors( + new ApiError() { + @Override + public String getName() { + return "TRIGGER_CHALLENGE_NOT_IMPLEMENTED"; + } + + @Override + public String getErrorCode() { + return "99244"; + } + + @Override + public String getMessage() { + return "Call to trigger sms or call challenge for OneLogin is not implemented."; + } + + @Override + public Map getMetadata() { + return null; + } + + @Override + public int getHttpStatusCode() { + return 501; + } + }) + .withExceptionMessage( + "Call to trigger sms or call challenge for OneLogin is not implemented.") + .build(); + } + + @Override + public AuthResponse mfaCheck(String stateToken, String deviceId, String otpToken) { + final SessionLoginTokenData sessionLoginToken = verifyFactor(deviceId, stateToken, otpToken); + final AuthData authData = new AuthData(); + final AuthResponse authResponse = new AuthResponse().setData(authData); + + authResponse.setStatus(AuthStatus.SUCCESS); + authData.setUserId(String.valueOf(sessionLoginToken.getUser().getId())); + authData.setUsername(sessionLoginToken.getUser().getUsername()); + + return authResponse; + } + + @Override + public Set getGroups(AuthData data) { + final UserData userData = getUserById(Long.parseLong(data.getUserId())); + return parseLdapGroups(userData.getMemberOf()); + } + + /** + * Takes the list of ldapGroups received from OneLogin and parses them in to a set of Strings + * + * @param ldapGroups A string consisting of ldap groups received from OneLogin + * @return A set of Strings consisting of the ldap groups that were parsed from the provided + * string + */ + protected Set parseLdapGroups(final String ldapGroups) { + Set groups = new HashSet<>(); + if (ldapGroups == null) { + return groups; + } + + // One Login double xml escapes entries + String escapedLdapGroups = + StringEscapeUtils.unescapeXml(StringEscapeUtils.unescapeXml(ldapGroups)); + + Iterable canonicalNameIterable; + Iterable piecesIterable; + Iterable canonicalNames = Splitter.on(";").split(escapedLdapGroups); + for (String canonicalName : canonicalNames) { + canonicalNameIterable = Splitter.on(",").split(canonicalName); + String[] pieces = Iterables.toArray(canonicalNameIterable, String.class); + + piecesIterable = Splitter.on("=").split(pieces[0]); + String[] parts = Iterables.toArray(piecesIterable, String.class); + if (parts.length >= 2) { + groups.add(parts[1]); + } else { + throw ApiException.newBuilder() + .withApiErrors(DefaultApiError.SERVICE_UNAVAILABLE) + .withExceptionMessage("OneLogin user info member-of field is malformed!") + .build(); + } + } + + return groups; + } + + /** + * Request for getting all user data by their ID. + * + * @param userId User ID + * @return User data + */ + protected UserData getUserById(final long userId) { + + final GetUserResponse getUserResponse = oneLoginClient.getUserById(userId); + + if (getUserResponse.getStatus().isError()) { + throw ApiException.newBuilder() + .withApiErrors(DefaultApiError.SERVICE_UNAVAILABLE) + .withExceptionMessage(getUserResponse.getStatus().getMessage()) + .build(); + } + + return getUserResponse.getData().get(0); + } + + /** + * Request for verifying a MFA factor. + * + * @param deviceId MFA device id + * @param stateToken State token + * @param otpToken Token from MFA device + * @return Session login token + */ + protected SessionLoginTokenData verifyFactor( + final String deviceId, final String stateToken, final String otpToken) { + + final VerifyFactorResponse verifyFactorResponse = + oneLoginClient.verifyFactor(deviceId, stateToken, otpToken); + + if (verifyFactorResponse.getStatus().isError()) { + String msg = + String.format( + "stateToken: %s failed to verify 2nd factor for reason: %s", + stateToken, verifyFactorResponse.getStatus().getMessage()); + + if (verifyFactorResponse.getStatus().getCode() == 401) { + throw ApiException.newBuilder() + .withApiErrors(DefaultApiError.AUTH_BAD_CREDENTIALS) + .withExceptionMessage(msg) + .build(); + } else { + throw ApiException.newBuilder() + .withApiErrors(DefaultApiError.GENERIC_BAD_REQUEST) + .withExceptionMessage(msg) + .build(); + } + } + + return verifyFactorResponse.getData().get(0); + } + + /** + * Request for creating a session login token. This is used to validate a user's credentials with + * OneLogin. + * + * @param username OneLogin username + * @param password OneLogin password + * @return Session login token + */ + protected SessionLoginTokenData createSessionLoginToken( + final String username, final String password) { + + final CreateSessionLoginTokenResponse createSessionLoginTokenResponse = + oneLoginClient.createSessionLoginToken(username, password); + + long statusCode = createSessionLoginTokenResponse.getStatus().getCode(); + + if (createSessionLoginTokenResponse.getStatus().isError()) { + String msg = + String.format( + "The user %s failed to authenticate for reason: %s", + username, createSessionLoginTokenResponse.getStatus().getMessage()); + if (statusCode == 400 + && StringUtils.startsWithIgnoreCase( + createSessionLoginTokenResponse.getStatus().getMessage(), "MFA")) { + throw ApiException.newBuilder() + .withApiErrors(DefaultApiError.MFA_SETUP_REQUIRED) + .withExceptionMessage(msg) + .build(); + } else if (statusCode == 400 || statusCode == 401) { + throw ApiException.newBuilder() + .withApiErrors(DefaultApiError.AUTH_BAD_CREDENTIALS) + .withExceptionMessage(msg) + .build(); + } else { + throw ApiException.newBuilder() + .withApiErrors(DefaultApiError.GENERIC_BAD_REQUEST) + .withExceptionMessage(msg) + .build(); + } + } + + return createSessionLoginTokenResponse.getData().get(0); + } +} diff --git a/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/onelogin/OneLoginClient.java b/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/onelogin/OneLoginClient.java new file mode 100644 index 000000000..d61643b51 --- /dev/null +++ b/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/onelogin/OneLoginClient.java @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.auth.connector.onelogin; + +import static com.nike.cerberus.auth.connector.config.OneLoginConfiguration.*; + +import com.google.common.collect.Maps; +import com.nike.backstopper.exception.ApiException; +import com.nike.cerberus.error.DefaultApiError; +import java.util.Map; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** Client for calling OneLogin APIs */ +@Component +class OneLoginClient { + + private final String clientId; + private final String clientSecret; + private final String subdomain; + private final OneLoginHttpClient httpClient; + + @Autowired + public OneLoginClient( + OneLoginConfigurationProperties oneLoginConfigurationProperties, + OneLoginHttpClient httpClient) { + + this.clientId = oneLoginConfigurationProperties.getClientId(); + this.clientSecret = oneLoginConfigurationProperties.getClientSecret(); + this.subdomain = oneLoginConfigurationProperties.getSubDomain(); + this.httpClient = httpClient; + } + + /** Attempt to login a user */ + public CreateSessionLoginTokenResponse createSessionLoginToken( + final String username, final String password) { + CreateSessionLoginTokenRequest request = + new CreateSessionLoginTokenRequest() + .setUsernameOrEmail(username) + .setPassword(password) + .setSubdomain(subdomain); + + return httpClient.execute( + "api/1/login/auth", + "POST", + buildAuthorizationBearerHeader(), + request, + CreateSessionLoginTokenResponse.class); + } + + /** Verify MFA */ + public VerifyFactorResponse verifyFactor( + final String deviceId, final String stateToken, final String otpToken) { + VerifyFactorRequest request = + new VerifyFactorRequest() + .setDeviceId(deviceId) + .setStateToken(stateToken) + .setOtpToken(otpToken); + + return httpClient.execute( + "api/1/login/verify_factor", + "POST", + buildAuthorizationBearerHeader(), + request, + VerifyFactorResponse.class); + } + + /** Get info about a user */ + public GetUserResponse getUserById(long userId) { + return httpClient.execute( + "api/1/users/" + userId, + "GET", + buildAuthorizationBearerHeader(), + null, + GetUserResponse.class); + } + + /** + * Builds a map containing the Authorization header with a valid bearer token. + * + * @return Map containing the Authorization header and value. + */ + protected Map buildAuthorizationBearerHeader() { + final Map headers = Maps.newHashMap(); + headers.put("Authorization", String.format("bearer:%s", requestAccessToken().getAccessToken())); + return headers; + } + + /** + * Requests an access token using the configured client id and secret. + * + * @return Access token + */ + protected GenerateTokenResponseData requestAccessToken() { + + final GenerateTokenResponse generateTokenResponse = + httpClient.execute( + "auth/oauth2/token", + "POST", + buildAuthorizationHeader(), + new GenerateTokenRequest(), + GenerateTokenResponse.class); + + if (generateTokenResponse.getStatus().isError()) { + throw ApiException.newBuilder() + .withApiErrors(DefaultApiError.SERVICE_UNAVAILABLE) + .withExceptionMessage("Failed to generate an access token with OneLogin!") + .build(); + } + + return generateTokenResponse.getData().get(0); + } + + /** Used in GenerateTokenRequest */ + protected Map buildAuthorizationHeader() { + final Map headers = Maps.newHashMap(); + headers.put( + "Authorization", String.format("client_id:%s, client_secret:%s", clientId, clientSecret)); + return headers; + } +} diff --git a/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/onelogin/OneLoginConfigurationProperties.java b/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/onelogin/OneLoginConfigurationProperties.java new file mode 100644 index 000000000..c9c9d2858 --- /dev/null +++ b/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/onelogin/OneLoginConfigurationProperties.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.auth.connector.onelogin; + +import javax.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class OneLoginConfigurationProperties { + + @NotBlank private String clientId; + + @NotBlank private String clientSecret; + + @NotBlank private String subDomain; + + @NotBlank( + message = + "You must specify the OneLogin API region, so that it can be used in the following template: 'https://api.${apiRegion}.onelogin.com'") + private String apiRegion; +} diff --git a/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/onelogin/OneLoginHttpClient.java b/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/onelogin/OneLoginHttpClient.java new file mode 100644 index 000000000..92cb6b540 --- /dev/null +++ b/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/onelogin/OneLoginHttpClient.java @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.auth.connector.onelogin; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.PropertyNamingStrategy; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.nike.backstopper.exception.ApiException; +import com.nike.cerberus.error.DefaultApiError; +import java.io.IOException; +import java.net.URI; +import java.nio.charset.Charset; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import javax.net.ssl.SSLException; +import okhttp3.*; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Component; + +/** A HttpClient for interacting with OneLogin */ +@Component +public class OneLoginHttpClient { + + private static final MediaType DEFAULT_MEDIA_TYPE = MediaType.parse("application/json"); + private static final String DEFAULT_ONELOGIN_API_URI_TEMPLATE = "https://api.%s.onelogin.com/"; + private static final int DEFAULT_TIMEOUT = 15; + private static final TimeUnit DEFAULT_TIMEOUT_UNIT = TimeUnit.SECONDS; + + private final URI oneloginApiUri; + private final OkHttpClient httpClient; + private final ObjectMapper objectMapper; + + static ObjectMapper getObjectMapper() { + var objectMapper = new ObjectMapper(); + objectMapper.findAndRegisterModules(); + objectMapper.setPropertyNamingStrategy(PropertyNamingStrategy.SNAKE_CASE); + objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + objectMapper.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS); + objectMapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + objectMapper.enable(SerializationFeature.WRITE_EMPTY_JSON_ARRAYS); + objectMapper.enable(SerializationFeature.INDENT_OUTPUT); + return objectMapper; + } + + public OneLoginHttpClient(OneLoginConfigurationProperties oneLoginConfigurationProperties) { + + this.oneloginApiUri = + URI.create( + String.format( + DEFAULT_ONELOGIN_API_URI_TEMPLATE, oneLoginConfigurationProperties.getApiRegion())); + + this.httpClient = + new OkHttpClient.Builder() + .connectTimeout(DEFAULT_TIMEOUT, DEFAULT_TIMEOUT_UNIT) + .writeTimeout(DEFAULT_TIMEOUT, DEFAULT_TIMEOUT_UNIT) + .readTimeout(DEFAULT_TIMEOUT, DEFAULT_TIMEOUT_UNIT) + .build(); + + this.objectMapper = getObjectMapper(); + } + + /** + * Executes the HTTP request based on the input parameters. + * + * @param path The Path to execute the request against + * @param method The HTTP method for the request + * @param headers HTTP Headers to include in the request + * @param requestBody The request body of the HTTP request + * @param responseClass The class of the response object + * @return Response from the server + */ + public M execute( + final String path, + final String method, + final Map headers, + final Object requestBody, + final Class responseClass) { + try { + Request request = buildRequest(buildUrl(path), method, headers, requestBody); + Response response = httpClient.newCall(request).execute(); + return parseResponseBody(response, responseClass); + } catch (IOException e) { + throw toApiException(e); + } + } + + /** + * Builds the full URL. + * + * @param path Path for the requested operation + * @return Full URL to execute a request against + */ + protected HttpUrl buildUrl(final String path) { + String baseUrl = oneloginApiUri.toString(); + + if (!StringUtils.endsWith(baseUrl, "/")) { + baseUrl += "/"; + } + + return HttpUrl.parse(baseUrl + path); + } + + /** + * Build the request + * + * @param url The URL to execute the request against + * @param method The HTTP method for the request + * @param requestBody The request body of the HTTP request + * @throws JsonProcessingException + */ + protected Request buildRequest( + HttpUrl url, String method, Map headers, Object requestBody) + throws JsonProcessingException { + Request.Builder requestBuilder = + new Request.Builder().url(url).addHeader("Accept", DEFAULT_MEDIA_TYPE.toString()); + + if (headers != null) { + headers.forEach(requestBuilder::addHeader); + } + + if (requestBody != null) { + requestBuilder + .addHeader("Content-Type", DEFAULT_MEDIA_TYPE.toString()) + .method( + method, + RequestBody.create( + DEFAULT_MEDIA_TYPE, + objectMapper.writeValueAsString(requestBody).getBytes(Charset.forName("UTF-8")))); + } else { + requestBuilder.method(method, null); + } + + return requestBuilder.build(); + } + + protected ApiException toApiException(IOException e) { + if (e instanceof SSLException + && e.getMessage() != null + && e.getMessage().contains("Unrecognized SSL message, plaintext connection?")) { + return ApiException.newBuilder() + .withApiErrors(DefaultApiError.SERVICE_UNAVAILABLE) + .withExceptionCause(e) + .withExceptionMessage( + "I/O error while communicating with OneLogin. Unrecognized SSL message may be due to a web proxy e.g. AnyConnect") + .build(); + } else { + return ApiException.newBuilder() + .withApiErrors(DefaultApiError.SERVICE_UNAVAILABLE) + .withExceptionCause(e) + .withExceptionMessage("I/O error while communicating with OneLogin.") + .build(); + } + } + + /** + * Convenience method for parsing the HTTP response and mapping it to a class. + * + * @param response The HTTP response object + * @param responseClass The class to map the response body to + * @param Represents the type to map to + * @return Deserialized object from the response body + */ + protected M parseResponseBody(final Response response, final Class responseClass) { + try { + return objectMapper.readValue(response.body().string(), responseClass); + } catch (IOException e) { + throw ApiException.newBuilder() + .withApiErrors(DefaultApiError.SERVICE_UNAVAILABLE) + .withExceptionCause(e) + .withExceptionMessage("Error parsing the response body from OneLogin.") + .build(); + } + } +} diff --git a/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/onelogin/ResponseStatus.java b/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/onelogin/ResponseStatus.java new file mode 100644 index 000000000..474cc1adf --- /dev/null +++ b/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/onelogin/ResponseStatus.java @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.auth.connector.onelogin; + +/** POJO representing the response status for all API calls. */ +class ResponseStatus { + + private String type; + + private String message; + + private long code; + + private boolean error; + + public String getType() { + return type; + } + + public ResponseStatus setType(String type) { + this.type = type; + return this; + } + + public String getMessage() { + return message; + } + + public ResponseStatus setMessage(String message) { + this.message = message; + return this; + } + + public long getCode() { + return code; + } + + public ResponseStatus setCode(long code) { + this.code = code; + return this; + } + + public boolean isError() { + return error; + } + + public ResponseStatus setError(boolean error) { + this.error = error; + return this; + } +} diff --git a/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/onelogin/SessionLoginTokenData.java b/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/onelogin/SessionLoginTokenData.java new file mode 100644 index 000000000..780d73322 --- /dev/null +++ b/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/onelogin/SessionLoginTokenData.java @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.auth.connector.onelogin; + +import java.util.List; + +/** POJO representing the payload of a create login session token response. */ +class SessionLoginTokenData { + + private String status; + + private SessionUser user; + + private String returnToUrl; + + private String sessionToken; + + private String stateToken; + + private String callBackUrl; + + private List devices; + + public String getStatus() { + return status; + } + + public SessionLoginTokenData setStatus(String status) { + this.status = status; + return this; + } + + public SessionUser getUser() { + return user; + } + + public SessionLoginTokenData setUser(SessionUser user) { + this.user = user; + return this; + } + + public String getReturnToUrl() { + return returnToUrl; + } + + public SessionLoginTokenData setReturnToUrl(String returnToUrl) { + this.returnToUrl = returnToUrl; + return this; + } + + public String getSessionToken() { + return sessionToken; + } + + public SessionLoginTokenData setSessionToken(String sessionToken) { + this.sessionToken = sessionToken; + return this; + } + + public String getStateToken() { + return stateToken; + } + + public SessionLoginTokenData setStateToken(String stateToken) { + this.stateToken = stateToken; + return this; + } + + public String getCallBackUrl() { + return callBackUrl; + } + + public SessionLoginTokenData setCallBackUrl(String callBackUrl) { + this.callBackUrl = callBackUrl; + return this; + } + + public List getDevices() { + return devices; + } + + public SessionLoginTokenData setDevices(List devices) { + this.devices = devices; + return this; + } +} diff --git a/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/onelogin/SessionUser.java b/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/onelogin/SessionUser.java new file mode 100644 index 000000000..c04c566b8 --- /dev/null +++ b/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/onelogin/SessionUser.java @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.auth.connector.onelogin; + +/** POJO representing the session user. */ +class SessionUser { + + private long id; + + private String email; + + private String username; + + private String firstname; + + private String lastname; + + public long getId() { + return id; + } + + public SessionUser setId(long id) { + this.id = id; + return this; + } + + public String getEmail() { + return email; + } + + public SessionUser setEmail(String email) { + this.email = email; + return this; + } + + public String getUsername() { + return username; + } + + public SessionUser setUsername(String username) { + this.username = username; + return this; + } + + public String getFirstname() { + return firstname; + } + + public SessionUser setFirstname(String firstname) { + this.firstname = firstname; + return this; + } + + public String getLastname() { + return lastname; + } + + public SessionUser setLastname(String lastname) { + this.lastname = lastname; + return this; + } +} diff --git a/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/onelogin/UserData.java b/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/onelogin/UserData.java new file mode 100644 index 000000000..c8afec79e --- /dev/null +++ b/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/onelogin/UserData.java @@ -0,0 +1,324 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.auth.connector.onelogin; + +import java.time.OffsetDateTime; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +/** POJO representing the payload of a get user response. */ +class UserData { + + private OffsetDateTime activatedAt; + + private OffsetDateTime createdAt; + + private String email; + + private String username; + + private String firstname; + + private long groupId; + + private long id; + + private long invalidLoginAttempts; + + private OffsetDateTime invitationSentAt; + + private OffsetDateTime lastLogin; + + private String lastname; + + private OffsetDateTime lockedUntil; + + private String notes; + + private String openidName; + + private OffsetDateTime passwordChangedAt; + + private String phone; + + private long status; + + private OffsetDateTime updatedAt; + + private String distinguishedName; + + private String externalId; + + private String directoryId; + + private String memberOf; + + private String samaccountname; + + private String userprincipalname; + + private String managerAdId; + + private List roleId = new LinkedList<>(); + + private Map customAttributes = new HashMap<>(); + + public OffsetDateTime getActivatedAt() { + return activatedAt; + } + + public UserData setActivatedAt(OffsetDateTime activatedAt) { + this.activatedAt = activatedAt; + return this; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public UserData setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + return this; + } + + public String getEmail() { + return email; + } + + public UserData setEmail(String email) { + this.email = email; + return this; + } + + public String getUsername() { + return username; + } + + public UserData setUsername(String username) { + this.username = username; + return this; + } + + public String getFirstname() { + return firstname; + } + + public UserData setFirstname(String firstname) { + this.firstname = firstname; + return this; + } + + public long getGroupId() { + return groupId; + } + + public UserData setGroupId(long groupId) { + this.groupId = groupId; + return this; + } + + public long getId() { + return id; + } + + public UserData setId(long id) { + this.id = id; + return this; + } + + public long getInvalidLoginAttempts() { + return invalidLoginAttempts; + } + + public UserData setInvalidLoginAttempts(long invalidLoginAttempts) { + this.invalidLoginAttempts = invalidLoginAttempts; + return this; + } + + public OffsetDateTime getInvitationSentAt() { + return invitationSentAt; + } + + public UserData setInvitationSentAt(OffsetDateTime invitationSentAt) { + this.invitationSentAt = invitationSentAt; + return this; + } + + public OffsetDateTime getLastLogin() { + return lastLogin; + } + + public UserData setLastLogin(OffsetDateTime lastLogin) { + this.lastLogin = lastLogin; + return this; + } + + public String getLastname() { + return lastname; + } + + public UserData setLastname(String lastname) { + this.lastname = lastname; + return this; + } + + public OffsetDateTime getLockedUntil() { + return lockedUntil; + } + + public UserData setLockedUntil(OffsetDateTime lockedUntil) { + this.lockedUntil = lockedUntil; + return this; + } + + public String getNotes() { + return notes; + } + + public UserData setNotes(String notes) { + this.notes = notes; + return this; + } + + public String getOpenidName() { + return openidName; + } + + public UserData setOpenidName(String openidName) { + this.openidName = openidName; + return this; + } + + public OffsetDateTime getPasswordChangedAt() { + return passwordChangedAt; + } + + public UserData setPasswordChangedAt(OffsetDateTime passwordChangedAt) { + this.passwordChangedAt = passwordChangedAt; + return this; + } + + public String getPhone() { + return phone; + } + + public UserData setPhone(String phone) { + this.phone = phone; + return this; + } + + public long getStatus() { + return status; + } + + public UserData setStatus(long status) { + this.status = status; + return this; + } + + public OffsetDateTime getUpdatedAt() { + return updatedAt; + } + + public UserData setUpdatedAt(OffsetDateTime updatedAt) { + this.updatedAt = updatedAt; + return this; + } + + public String getDistinguishedName() { + return distinguishedName; + } + + public UserData setDistinguishedName(String distinguishedName) { + this.distinguishedName = distinguishedName; + return this; + } + + public String getExternalId() { + return externalId; + } + + public UserData setExternalId(String externalId) { + this.externalId = externalId; + return this; + } + + public String getDirectoryId() { + return directoryId; + } + + public UserData setDirectoryId(String directoryId) { + this.directoryId = directoryId; + return this; + } + + public String getMemberOf() { + return memberOf; + } + + public UserData setMemberOf(String memberOf) { + this.memberOf = memberOf; + return this; + } + + public String getSamaccountname() { + return samaccountname; + } + + public UserData setSamaccountname(String samaccountname) { + this.samaccountname = samaccountname; + return this; + } + + public String getUserprincipalname() { + return userprincipalname; + } + + public UserData setUserprincipalname(String userprincipalname) { + this.userprincipalname = userprincipalname; + return this; + } + + public String getManagerAdId() { + return managerAdId; + } + + public UserData setManagerAdId(String managerAdId) { + this.managerAdId = managerAdId; + return this; + } + + public List getRoleId() { + return roleId; + } + + public UserData setRoleId(List roleId) { + this.roleId = roleId; + return this; + } + + public Map getCustomAttributes() { + return customAttributes; + } + + public UserData setCustomAttributes(Map customAttributes) { + this.customAttributes = customAttributes; + return this; + } +} diff --git a/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/onelogin/VerifyFactorRequest.java b/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/onelogin/VerifyFactorRequest.java new file mode 100644 index 000000000..f5c244750 --- /dev/null +++ b/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/onelogin/VerifyFactorRequest.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.auth.connector.onelogin; + +/** POJO representing the payload of a verify factor request. */ +class VerifyFactorRequest { + + private String deviceId; + + private String stateToken; + + private String otpToken; + + public String getDeviceId() { + return deviceId; + } + + public VerifyFactorRequest setDeviceId(String deviceId) { + this.deviceId = deviceId; + return this; + } + + public String getStateToken() { + return stateToken; + } + + public VerifyFactorRequest setStateToken(String stateToken) { + this.stateToken = stateToken; + return this; + } + + public String getOtpToken() { + return otpToken; + } + + public VerifyFactorRequest setOtpToken(String otpToken) { + this.otpToken = otpToken; + return this; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + VerifyFactorRequest that = (VerifyFactorRequest) o; + + if (deviceId != null ? !deviceId.equals(that.deviceId) : that.deviceId != null) return false; + if (stateToken != null ? !stateToken.equals(that.stateToken) : that.stateToken != null) + return false; + return otpToken != null ? otpToken.equals(that.otpToken) : that.otpToken == null; + } + + @Override + public int hashCode() { + int result = deviceId != null ? deviceId.hashCode() : 0; + result = 31 * result + (stateToken != null ? stateToken.hashCode() : 0); + result = 31 * result + (otpToken != null ? otpToken.hashCode() : 0); + return result; + } +} diff --git a/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/onelogin/VerifyFactorResponse.java b/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/onelogin/VerifyFactorResponse.java new file mode 100644 index 000000000..d05c6382a --- /dev/null +++ b/cerberus-auth-connector-onelogin/src/main/java/com/nike/cerberus/auth/connector/onelogin/VerifyFactorResponse.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.auth.connector.onelogin; + +import java.util.List; + +/** POJO representing the payload of a verify factor response. */ +class VerifyFactorResponse { + + private ResponseStatus status; + + private List data; + + public ResponseStatus getStatus() { + return status; + } + + public VerifyFactorResponse setStatus(ResponseStatus status) { + this.status = status; + return this; + } + + public List getData() { + return data; + } + + public VerifyFactorResponse setData(List data) { + this.data = data; + return this; + } +} diff --git a/cerberus-auth-connector-onelogin/src/test/java/com/nike/cerberus/auth/connector/onelogin/GenerateTokenRequestTest.java b/cerberus-auth-connector-onelogin/src/test/java/com/nike/cerberus/auth/connector/onelogin/GenerateTokenRequestTest.java new file mode 100644 index 000000000..ee5d96bee --- /dev/null +++ b/cerberus-auth-connector-onelogin/src/test/java/com/nike/cerberus/auth/connector/onelogin/GenerateTokenRequestTest.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.auth.connector.onelogin; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class GenerateTokenRequestTest { + + @Test + public void test_getGrantType() { + assertEquals("client_credentials", new GenerateTokenRequest().getGrantType()); + } + + @Test + public void test_setGrantType() { + assertEquals("foo", new GenerateTokenRequest().setGrantType("foo").getGrantType()); + } + + @Test + public void test_equals() { + assertEquals(new GenerateTokenRequest(), new GenerateTokenRequest()); + } + + @Test + public void test_hashCode() { + assertEquals(new GenerateTokenRequest().hashCode(), new GenerateTokenRequest().hashCode()); + assertTrue( + new GenerateTokenRequest().hashCode() + != new GenerateTokenRequest().setGrantType("foo").hashCode()); + } +} diff --git a/cerberus-auth-connector-onelogin/src/test/java/com/nike/cerberus/auth/connector/onelogin/OneLoginAuthConnectorTest.java b/cerberus-auth-connector-onelogin/src/test/java/com/nike/cerberus/auth/connector/onelogin/OneLoginAuthConnectorTest.java new file mode 100644 index 000000000..fe0703dde --- /dev/null +++ b/cerberus-auth-connector-onelogin/src/test/java/com/nike/cerberus/auth/connector/onelogin/OneLoginAuthConnectorTest.java @@ -0,0 +1,403 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.auth.connector.onelogin; + +import static com.nike.cerberus.error.DefaultApiError.MFA_SETUP_REQUIRED; +import static org.junit.Assert.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.google.common.collect.Lists; +import com.google.common.collect.Sets; +import com.nike.backstopper.exception.ApiException; +import com.nike.cerberus.auth.connector.AuthResponse; +import com.nike.cerberus.auth.connector.AuthStatus; +import com.nike.cerberus.error.DefaultApiError; +import java.util.Set; +import org.junit.Before; +import org.junit.Test; + +public class OneLoginAuthConnectorTest { + + // constants used by several tests + private static final long USER_ID = 1001L; + private static final String USERNAME = "USERNAME"; + private static final String PASSWORD = "PASSWORD"; + private static final Long DEVICE_ID = 1001L; + private static final String DEVICE_TYPE = "DEVICE_TYPE"; + private static final String STATE_TOKEN = "STATE_TOKEN"; + private static final String OTP_TOKEN = "OTP_TOKEN"; + + // mock + private OneLoginClient oneLoginClient = null; + + // class under test + private OneLoginAuthConnector oneLoginAuthConnector = null; + + @Before + public void setup() { + oneLoginClient = mock(OneLoginClient.class); + oneLoginAuthConnector = new OneLoginAuthConnector(oneLoginClient); + } + + @Test + public void test_authenticate_with_no_mfa() { + ResponseStatus status = new ResponseStatus(); + status.setError(false); + + CreateSessionLoginTokenResponse createSessionLoginTokenResponse = + new CreateSessionLoginTokenResponse(); + createSessionLoginTokenResponse.setStatus(status); + + SessionUser user = new SessionUser(); + user.setId(USER_ID); + user.setUsername(USERNAME); + + SessionLoginTokenData sessionLoginTokenData = new SessionLoginTokenData(); + sessionLoginTokenData.setUser(user); + + createSessionLoginTokenResponse.setData(Lists.newArrayList(sessionLoginTokenData)); + + when(oneLoginClient.createSessionLoginToken(USERNAME, PASSWORD)) + .thenReturn(createSessionLoginTokenResponse); + + // invoke method under test + AuthResponse response = oneLoginAuthConnector.authenticate(USERNAME, PASSWORD); + + assertEquals(Long.toString(user.getId()), response.getData().getUserId()); + assertEquals(user.getUsername(), response.getData().getUsername()); + assertEquals(AuthStatus.SUCCESS, response.getStatus()); + } + + @Test + public void test_authenticate_with_mfa_required() { + ResponseStatus status = new ResponseStatus(); + status.setError(false); + + CreateSessionLoginTokenResponse createSessionLoginTokenResponse = + new CreateSessionLoginTokenResponse(); + createSessionLoginTokenResponse.setStatus(status); + + SessionUser user = new SessionUser(); + user.setId(USER_ID); + user.setUsername(USERNAME); + + MfaDevice device = new MfaDevice(); + device.setDeviceId(DEVICE_ID); + device.setDeviceType(DEVICE_TYPE); + + SessionLoginTokenData sessionLoginTokenData = new SessionLoginTokenData(); + sessionLoginTokenData.setUser(user); + sessionLoginTokenData.setStateToken(STATE_TOKEN); + sessionLoginTokenData.setDevices(Lists.newArrayList(device)); + + createSessionLoginTokenResponse.setData(Lists.newArrayList(sessionLoginTokenData)); + + when(oneLoginClient.createSessionLoginToken(USERNAME, PASSWORD)) + .thenReturn(createSessionLoginTokenResponse); + + // invoke method under test + AuthResponse response = oneLoginAuthConnector.authenticate(USERNAME, PASSWORD); + + assertEquals(Long.toString(user.getId()), response.getData().getUserId()); + assertEquals(user.getUsername(), response.getData().getUsername()); + assertEquals(AuthStatus.MFA_REQUIRED, response.getStatus()); + assertEquals(DEVICE_ID.toString(), response.getData().getDevices().get(0).getId()); + assertEquals(DEVICE_TYPE, response.getData().getDevices().get(0).getName()); + } + + @Test + public void test_authenticate_when_mfa_is_not_setup() { + + setupMockWhereLoginGivesError(400, "mfa something error message"); + + try { + // invoke method under test + oneLoginAuthConnector.authenticate(USERNAME, PASSWORD); + + fail("expected exception not thrown"); + } catch (ApiException e) { + assertTrue(e.getApiErrors().contains(MFA_SETUP_REQUIRED)); + assertFalse(e.getApiErrors().contains(DefaultApiError.AUTH_BAD_CREDENTIALS)); + } + } + + @Test + public void test_authenticate_with_bad_creds() { + setupMockWhereLoginGivesError(401, "any other error message"); + + try { + // invoke method under test + oneLoginAuthConnector.authenticate(USERNAME, PASSWORD); + + fail("expected exception not thrown"); + } catch (ApiException e) { + assertTrue(e.getApiErrors().contains(DefaultApiError.AUTH_BAD_CREDENTIALS)); + assertFalse(e.getApiErrors().contains(MFA_SETUP_REQUIRED)); + } + } + + @Test + public void test_authenticate_with_500_response() { + setupMockWhereLoginGivesError(500L, "any error message"); + + try { + // invoke method under test + oneLoginAuthConnector.authenticate(USERNAME, PASSWORD); + + fail("expected exception not thrown"); + } catch (ApiException e) { + assertTrue(e.getApiErrors().contains(DefaultApiError.GENERIC_BAD_REQUEST)); + } + } + + private void setupMockWhereLoginGivesError(long statusCode, String message) { + ResponseStatus status = new ResponseStatus(); + status.setCode(statusCode); + status.setMessage(message); + status.setError(true); + + CreateSessionLoginTokenResponse createSessionLoginTokenResponse = + new CreateSessionLoginTokenResponse(); + createSessionLoginTokenResponse.setStatus(status); + + when(oneLoginClient.createSessionLoginToken(USERNAME, PASSWORD)) + .thenReturn(createSessionLoginTokenResponse); + } + + @Test(expected = ApiException.class) + public void test_triggerChallenge() { + + String stateToken = "state token"; + String deviceId = "device id"; + + oneLoginAuthConnector.triggerChallenge(stateToken, deviceId); + } + + @Test + public void test_mfaCheck() { + SessionUser user = new SessionUser(); + user.setId(USER_ID); + user.setUsername(USERNAME); + + SessionLoginTokenData sessionLoginTokenData = new SessionLoginTokenData(); + sessionLoginTokenData.setUser(user); + + VerifyFactorResponse verifyFactorResponse = mock(VerifyFactorResponse.class); + when(verifyFactorResponse.getStatus()).thenReturn(new ResponseStatus()); + when(verifyFactorResponse.getData()).thenReturn(Lists.newArrayList(sessionLoginTokenData)); + when(oneLoginClient.verifyFactor(DEVICE_ID.toString(), STATE_TOKEN, OTP_TOKEN)) + .thenReturn(verifyFactorResponse); + + // invoke method under test + AuthResponse response = + oneLoginAuthConnector.mfaCheck(STATE_TOKEN, DEVICE_ID.toString(), OTP_TOKEN); + assertEquals(Long.toString(user.getId()), response.getData().getUserId()); + assertEquals(user.getUsername(), response.getData().getUsername()); + assertEquals(AuthStatus.SUCCESS, response.getStatus()); + } + + @Test + public void test_parseLdapGroups() { + String ldapGroups = + "CN=Application.foo.users,OU=Application,OU=Groups,DC=ad,DC=acme,DC=com;CN=Application.bar.users,OU=Application,OU=Groups,DC=ad,DC=acme,DC=com"; + + // invoke method under test + Set actualResults = oneLoginAuthConnector.parseLdapGroups(ldapGroups); + + Set expectedResults = Sets.newHashSet("Application.bar.users", "Application.foo.users"); + assertEquals(expectedResults, actualResults); + } + + @Test + public void test_parseLdapGroups_handles_null() { + Set actualResults = oneLoginAuthConnector.parseLdapGroups(null); + Set expectedResults = Sets.newHashSet(); + assertEquals(expectedResults, actualResults); + } + + @Test + public void test_getUserById() { + ResponseStatus status = new ResponseStatus(); + status.setError(false); + + UserData userData = new UserData(); + userData.setId(USER_ID); + + GetUserResponse getUserResponse = new GetUserResponse(); + getUserResponse.setData(Lists.newArrayList(userData)); + getUserResponse.setStatus(status); + + when(oneLoginClient.getUserById(USER_ID)).thenReturn(getUserResponse); + + // invoke method under test + UserData actualData = oneLoginAuthConnector.getUserById(USER_ID); + + assertEquals(userData.getId(), actualData.getId()); + } + + @Test + public void test_getUserById_gives_error() { + ResponseStatus status = new ResponseStatus(); + status.setError(true); + GetUserResponse getUserResponse = new GetUserResponse(); + getUserResponse.setStatus(status); + + when(oneLoginClient.getUserById(USER_ID)).thenReturn(getUserResponse); + + try { + // invoke method under test + oneLoginAuthConnector.getUserById(USER_ID); + + fail("expected exception not thrown"); + } catch (ApiException e) { + assertTrue(e.getApiErrors().contains(DefaultApiError.SERVICE_UNAVAILABLE)); + } + } + + @Test + public void test_verifyFactor_with_400_error() { + + setupMockWhereVerifyGivesError(401, "any error message"); + + try { + // invoke method under test + oneLoginAuthConnector.verifyFactor(DEVICE_ID.toString(), STATE_TOKEN, OTP_TOKEN); + + fail("expected exception not thrown"); + } catch (ApiException e) { + assertTrue(e.getApiErrors().contains(DefaultApiError.AUTH_BAD_CREDENTIALS)); + } + } + + @Test + public void test_verifyFactor_with_500_error() { + + setupMockWhereVerifyGivesError(500L, "any error message"); + + try { + // invoke method under test + oneLoginAuthConnector.verifyFactor(DEVICE_ID.toString(), STATE_TOKEN, OTP_TOKEN); + + fail("expected exception not thrown"); + } catch (ApiException e) { + assertTrue(e.getApiErrors().contains(DefaultApiError.GENERIC_BAD_REQUEST)); + } + } + + private void setupMockWhereVerifyGivesError(long statusCode, String message) { + ResponseStatus status = new ResponseStatus(); + status.setCode(statusCode); + status.setMessage(message); + status.setError(true); + + VerifyFactorResponse verifyFactorResponse = new VerifyFactorResponse(); + verifyFactorResponse.setStatus(status); + + when(oneLoginClient.verifyFactor(DEVICE_ID.toString(), STATE_TOKEN, OTP_TOKEN)) + .thenReturn(verifyFactorResponse); + } + + @Test + public void test_createSessionLoginToken() { + ResponseStatus status = new ResponseStatus(); + status.setError(false); + + CreateSessionLoginTokenResponse createSessionLoginTokenResponse = + new CreateSessionLoginTokenResponse(); + createSessionLoginTokenResponse.setStatus(status); + + SessionLoginTokenData expectedData = mock(SessionLoginTokenData.class); + createSessionLoginTokenResponse.setData(Lists.newArrayList(expectedData)); + + when(oneLoginClient.createSessionLoginToken(USERNAME, PASSWORD)) + .thenReturn(createSessionLoginTokenResponse); + + // invoke method under test + SessionLoginTokenData actualData = + oneLoginAuthConnector.createSessionLoginToken(USERNAME, PASSWORD); + + assertEquals(expectedData, actualData); + } + + @Test + public void test_createSessionLoginToken_fails_with_401_when_bad_username_is_given() { + ResponseStatus status = new ResponseStatus(); + status.setError(true); + status.setCode(400); + status.setMessage("bad request"); + + CreateSessionLoginTokenResponse createSessionLoginTokenResponse = + new CreateSessionLoginTokenResponse(); + createSessionLoginTokenResponse.setStatus(status); + + when(oneLoginClient.createSessionLoginToken(USERNAME, PASSWORD)) + .thenReturn(createSessionLoginTokenResponse); + + // invoke method under test + try { + oneLoginAuthConnector.createSessionLoginToken(USERNAME, PASSWORD); + } catch (ApiException ae) { + assertEquals(401, ae.getApiErrors().get(0).getHttpStatusCode()); + } + } + + @Test + public void test_createSessionLoginToken_fails_with_401_when_bad_password_is_given() { + ResponseStatus status = new ResponseStatus(); + status.setError(true); + status.setCode(401); + status.setMessage("Authentication Failed"); + + CreateSessionLoginTokenResponse createSessionLoginTokenResponse = + new CreateSessionLoginTokenResponse(); + createSessionLoginTokenResponse.setStatus(status); + + when(oneLoginClient.createSessionLoginToken(USERNAME, PASSWORD)) + .thenReturn(createSessionLoginTokenResponse); + + // invoke method under test + try { + oneLoginAuthConnector.createSessionLoginToken(USERNAME, PASSWORD); + } catch (ApiException ae) { + assertEquals(401, ae.getApiErrors().get(0).getHttpStatusCode()); + } + } + + @Test + public void test_createSessionLoginToken_fails_with_when_MFA_setup_is_required() { + ResponseStatus status = new ResponseStatus(); + status.setError(true); + status.setCode(400); + status.setMessage("MFA: rest doesnt matter"); + + CreateSessionLoginTokenResponse createSessionLoginTokenResponse = + new CreateSessionLoginTokenResponse(); + createSessionLoginTokenResponse.setStatus(status); + + when(oneLoginClient.createSessionLoginToken(USERNAME, PASSWORD)) + .thenReturn(createSessionLoginTokenResponse); + + // invoke method under test + try { + oneLoginAuthConnector.createSessionLoginToken(USERNAME, PASSWORD); + } catch (ApiException ae) { + assertEquals( + MFA_SETUP_REQUIRED.getHttpStatusCode(), ae.getApiErrors().get(0).getHttpStatusCode()); + } + } +} diff --git a/cerberus-auth-connector-onelogin/src/test/java/com/nike/cerberus/auth/connector/onelogin/OneLoginPojoTest.java b/cerberus-auth-connector-onelogin/src/test/java/com/nike/cerberus/auth/connector/onelogin/OneLoginPojoTest.java new file mode 100644 index 000000000..7f1015030 --- /dev/null +++ b/cerberus-auth-connector-onelogin/src/test/java/com/nike/cerberus/auth/connector/onelogin/OneLoginPojoTest.java @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.auth.connector.onelogin; + +import com.google.common.collect.Lists; +import com.openpojo.reflection.PojoClass; +import com.openpojo.reflection.impl.PojoClassFactory; +import com.openpojo.validation.Validator; +import com.openpojo.validation.ValidatorBuilder; +import com.openpojo.validation.rule.impl.GetterMustExistRule; +import com.openpojo.validation.rule.impl.SetterMustExistRule; +import com.openpojo.validation.test.impl.GetterTester; +import com.openpojo.validation.test.impl.SetterTester; +import java.util.List; +import java.util.stream.Collectors; +import org.junit.Test; + +public class OneLoginPojoTest { + + @Test + public void test_pojo_structure_and_behavior() { + + List classes = + Lists.newArrayList( + CreateSessionLoginTokenRequest.class, + CreateSessionLoginTokenResponse.class, + GenerateTokenRequest.class, + GenerateTokenResponse.class, + GenerateTokenResponseData.class, + GetUserResponse.class, + MfaDevice.class, + ResponseStatus.class, + SessionLoginTokenData.class, + SessionUser.class, + UserData.class, + VerifyFactorRequest.class, + VerifyFactorResponse.class); + + List pojoClasses = + classes.stream().map(PojoClassFactory::getPojoClass).collect(Collectors.toList()); + + Validator validator = + ValidatorBuilder.create() + .with(new GetterMustExistRule()) + .with(new SetterMustExistRule()) + .with(new SetterTester()) + .with(new GetterTester()) + .build(); + + validator.validate(pojoClasses); + } +} diff --git a/cerberus-core/cerberus-core.gradle b/cerberus-core/cerberus-core.gradle new file mode 100644 index 000000000..f3e45b679 --- /dev/null +++ b/cerberus-core/cerberus-core.gradle @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +dependencies { + api project(":cerberus-domain") + api 'org.springframework.boot:spring-boot-starter-web' + api 'org.springframework.boot:spring-boot-starter-security' + + // Error management + api 'com.nike.backstopper:backstopper-spring-web-mvc:0.12.0' + + // Metrics + api 'io.dropwizard.metrics:metrics-core:4.1.1' + + // Misc + api "io.github.resilience4j:resilience4j-all:${versions.resilience4j}" + api "com.squareup.okhttp3:okhttp:4.2.2" + api 'com.github.ben-manes.caffeine:caffeine:2.8.0' + + // TODO jav 8 -> java 11 error, verify and document why this is required + api "jakarta.xml.bind:jakarta.xml.bind-api:2.3.2" + api "org.glassfish.jaxb:jaxb-runtime:2.3.2" + + // Find bugs annotations + api group: 'com.google.code.findbugs', name: 'annotations', version: '3.0.1' + + api group: 'com.google.guava', name: 'guava', version: '28.1-jre' +} diff --git a/cerberus-core/src/main/java/com/nike/cerberus/auth/connector/AuthConnector.java b/cerberus-core/src/main/java/com/nike/cerberus/auth/connector/AuthConnector.java new file mode 100644 index 000000000..e8fe4323c --- /dev/null +++ b/cerberus-core/src/main/java/com/nike/cerberus/auth/connector/AuthConnector.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.auth.connector; + +import java.util.Set; + +public interface AuthConnector { + + AuthResponse authenticate(final String username, final String password); + + AuthResponse triggerChallenge(final String stateToken, final String deviceId); + + AuthResponse mfaCheck(final String stateToken, final String deviceId, final String otpToken); + + Set getGroups(final AuthData data); +} diff --git a/cerberus-core/src/main/java/com/nike/cerberus/auth/connector/AuthData.java b/cerberus-core/src/main/java/com/nike/cerberus/auth/connector/AuthData.java new file mode 100644 index 000000000..8b8d70570 --- /dev/null +++ b/cerberus-core/src/main/java/com/nike/cerberus/auth/connector/AuthData.java @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.auth.connector; + +import com.nike.cerberus.domain.AuthTokenResponse; +import java.util.LinkedList; +import java.util.List; + +/** Represents the authentication data returned by the auth connector. */ +public class AuthData { + + private String userId; + + private String username; + + private String stateToken; + + private List devices = new LinkedList<>(); + + private AuthTokenResponse clientToken; + + public String getUserId() { + return userId; + } + + public AuthData setUserId(String userId) { + this.userId = userId; + return this; + } + + public String getUsername() { + return username; + } + + public AuthData setUsername(String username) { + this.username = username; + return this; + } + + public String getStateToken() { + return stateToken; + } + + public AuthData setStateToken(String stateToken) { + this.stateToken = stateToken; + return this; + } + + public List getDevices() { + return devices; + } + + public AuthData setDevices(List devices) { + this.devices = devices; + return this; + } + + public AuthTokenResponse getClientToken() { + return clientToken; + } + + public AuthData setClientToken(AuthTokenResponse clientToken) { + this.clientToken = clientToken; + return this; + } +} diff --git a/cerberus-core/src/main/java/com/nike/cerberus/auth/connector/AuthMfaDevice.java b/cerberus-core/src/main/java/com/nike/cerberus/auth/connector/AuthMfaDevice.java new file mode 100644 index 000000000..f1601392a --- /dev/null +++ b/cerberus-core/src/main/java/com/nike/cerberus/auth/connector/AuthMfaDevice.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.auth.connector; + +/** Represents a MFA device returned by the auth connector. */ +public class AuthMfaDevice { + + private String id; + + private String name; + + private boolean requiresTrigger; + + public String getId() { + return id; + } + + public AuthMfaDevice setId(String id) { + this.id = id; + return this; + } + + public String getName() { + return name; + } + + public AuthMfaDevice setName(String name) { + this.name = name; + return this; + } + + public boolean getRequiresTrigger() { + return requiresTrigger; + } + + public AuthMfaDevice setRequiresTrigger(boolean requiresTrigger) { + this.requiresTrigger = requiresTrigger; + return this; + } +} diff --git a/cerberus-core/src/main/java/com/nike/cerberus/auth/connector/AuthResponse.java b/cerberus-core/src/main/java/com/nike/cerberus/auth/connector/AuthResponse.java new file mode 100644 index 000000000..27fcfeecc --- /dev/null +++ b/cerberus-core/src/main/java/com/nike/cerberus/auth/connector/AuthResponse.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.auth.connector; + +/** Authentication response from the auth connector. */ +public class AuthResponse { + + private AuthStatus status; + + private AuthData data; + + public AuthStatus getStatus() { + return status; + } + + public AuthResponse setStatus(AuthStatus status) { + this.status = status; + return this; + } + + public AuthData getData() { + return data; + } + + public AuthResponse setData(AuthData data) { + this.data = data; + return this; + } +} diff --git a/cerberus-core/src/main/java/com/nike/cerberus/auth/connector/AuthStatus.java b/cerberus-core/src/main/java/com/nike/cerberus/auth/connector/AuthStatus.java new file mode 100644 index 000000000..ef319a5d5 --- /dev/null +++ b/cerberus-core/src/main/java/com/nike/cerberus/auth/connector/AuthStatus.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.auth.connector; + +import com.fasterxml.jackson.annotation.JsonValue; + +/** Auth response status codes. */ +public enum AuthStatus { + SUCCESS("success"), + + MFA_REQUIRED("mfa_req"), + + MFA_CHALLENGE("mfa_challenge"); + + private final String status; + + AuthStatus(final String status) { + this.status = status; + } + + @JsonValue + public String code() { + return status; + } +} diff --git a/cerberus-core/src/main/java/com/nike/cerberus/error/DefaultApiError.java b/cerberus-core/src/main/java/com/nike/cerberus/error/DefaultApiError.java new file mode 100644 index 000000000..c264a1b92 --- /dev/null +++ b/cerberus-core/src/main/java/com/nike/cerberus/error/DefaultApiError.java @@ -0,0 +1,296 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.error; + +import static javax.servlet.http.HttpServletResponse.*; + +import com.nike.backstopper.apierror.ApiError; +import com.nike.backstopper.apierror.ApiErrorBase; +import java.util.Map; +import java.util.UUID; + +/** + * Contains the application-specific errors that can occur. Maps these application-specific errors + * to appropriate Http Status codes, and contains appropriate messages for them. These can be thrown + * with {@link com.nike.backstopper.exception.ApiException} to garner error-specific information for + * this project + */ +public enum DefaultApiError implements ApiError { + + /** Required request body is missing. */ + REQUEST_BODY_MISSING(99000, "Request body required", SC_BAD_REQUEST), + + /** Request body is malformed. */ + REQUEST_BODY_MALFORMED(99001, "Request body malformed.", SC_BAD_REQUEST), + + /** IAM Role account id is malformed. */ + IAM_ROLE_ACCT_ID_INVALID(99102, "AWS account id is malformed.", SC_BAD_REQUEST), + + /** IAM Role account id is blank */ + AUTH_IAM_ROLE_NAME_INVALID(99103, "AWS IAM role name is invalid.", SC_BAD_REQUEST), + + /** IAM Role account id is blank */ + AUTH_IAM_ROLE_AWS_REGION_BLANK(99104, "AWS region is malformed.", SC_BAD_REQUEST), + + /** X-Vault-Token or X-Cerberus-Token header was blank or invalid. */ + AUTH_TOKEN_INVALID( + 99105, "X-Vault-Token or X-Cerberus-Token header is malformed or invalid.", SC_UNAUTHORIZED), + + /** Supplied credentials are invalid. */ + AUTH_BAD_CREDENTIALS(99106, "Invalid credentials", SC_UNAUTHORIZED), + + /** Category display name is blank. */ + CATEGORY_DISPLAY_NAME_BLANK(99200, "Display name may not be blank.", SC_BAD_REQUEST), + + /** Category path is blank. */ + CATEGORY_DISPLAY_NAME_TOO_LONG( + 99201, "Display name must be 255 characters or less.", SC_BAD_REQUEST), + + /** User group name is blank. */ + USER_GROUP_NAME_BLANK(99202, "User group name may not be blank.", SC_BAD_REQUEST), + + /** User group role id is invalid. */ + USER_GROUP_ROLE_ID_INVALID(99203, "User group role id is invalid.", SC_BAD_REQUEST), + + /** IAM Role AWS account id is blank. */ + IAM_ROLE_ACCT_ID_BLANK( + 99204, "The AWS account id for the IAM role may not be blank.", SC_BAD_REQUEST), + + /** IAM Role name is blank. */ + IAM_ROLE_NAME_INVALID( + 99205, + "The AWS IAM role name is invalid. Alpha-numeric characters and the dash (-) are valid.", + SC_BAD_REQUEST), + + /** IAM Role role id is invalid. */ + IAM_ROLE_ROLE_ID_INVALID(99206, "The role id for the IAM role is invalid.", SC_BAD_REQUEST), + + /** SDB Category id is invalid. */ + SDB_CATEGORY_ID_INVALID(99207, "The category id is invalid.", SC_BAD_REQUEST), + + /** SDB name field is blank. */ + SDB_NAME_BLANK(99208, "The name may not be blank.", SC_BAD_REQUEST), + + /** SDB owner field is blank. */ + SDB_OWNER_BLANK(99209, "The owner may not be blank.", SC_BAD_REQUEST), + + /** SDB name isn't unique. */ + SDB_UNIQUE_NAME(99210, "Duplicate SDB names are not allowed.", SC_BAD_REQUEST), + + /** SDB name too long */ + SDB_NAME_TOO_LONG(99212, "Name may not exceed 100 characters.", SC_BAD_REQUEST), + + /** SDB description too long */ + SDB_DESCRIPTION_TOO_LONG(99213, "Description may not exceed 1000 characters.", SC_BAD_REQUEST), + + /** SDB owner too long */ + SDB_OWNER_TOO_LONG(99214, "Owner may not exceed 255 characters.", SC_BAD_REQUEST), + + /** The AWS region specified is invalid. */ + AUTH_IAM_ROLE_AWS_REGION_INVALID(99215, "Invalid AWS region.", SC_BAD_REQUEST), + + /** The IAM Role + Region don't have a KMS key provisioned to encrypt the auth response. */ + AUTH_IAM_PRINCIPAL_INVALID(99216, "The specified IAM principal is not valid.", SC_BAD_REQUEST), + + /** IAM Role permission on SDB specifies in invalid AWS region. */ + SDB_IAM_ROLE_PERMISSION_AWS_REGION_INVALID( + 99217, "Invalid AWS region specified for the IAM role.", SC_BAD_REQUEST), + + /** IAM Role permission on SDB specifies in invalid AWS region. */ + SDB_IAM_ROLE_PERMISSION_IAM_ROLE_INVALID( + 99226, "Invalid AWS IAM role specified for the SDB.", SC_BAD_REQUEST), + + /** User group permissions contain duplicate entries. */ + SDB_USER_GROUP_REPEATED( + 99218, "The user group permissions contains duplicate entries.", SC_BAD_REQUEST), + + /** IAM role permissions contain duplicate entries. */ + SDB_IAM_ROLE_REPEATED( + 99219, "The IAM role permissions contains duplicate entries.", SC_BAD_REQUEST), + + /** Owner should not be included in the user group permission set. */ + SDB_OWNER_IN_USER_GROUP_PERMS( + 99220, "The owner can not be included in the user group permissions.", SC_BAD_REQUEST), + + /** SDB has too many owners */ + SDB_TOO_MANY_OWNERS(99221, "The SDB has more than one owners!", SC_INTERNAL_SERVER_ERROR), + + /** + * Authentication error for when a user attempts to login and MFA is required but not setup on + * their account. + */ + MFA_SETUP_REQUIRED( + 99222, + "MFA is required. Please set up a supported device, either Okta Verify or Google Authenticator.", + SC_BAD_REQUEST), + + /** The IAM Role + Region don't have a KMS key provisioned to encrypt the auth response. */ + AUTH_IAM_ROLE_REJECTED( + 99223, + "KMS rejected the IAM Role ARN with an InvalidArnException.", + SC_INTERNAL_SERVER_ERROR), + + /** The IAM Role + Region don't have a KMS key provisioned to encrypt the auth response. */ + INVALID_QUERY_PARAMS(99224, "Invalid query params", SC_BAD_REQUEST), + + /** Failed to validate that the KMS key policy was valid */ + FAILED_TO_VALIDATE_KMS_KEY_POLICY( + 99225, "Failed to validate KMS key policy", SC_INTERNAL_SERVER_ERROR), + + /** IAM Role permission on SDB specifies in invalid AWS region. */ + SDB_IAM_PRINCIPAL_PERMISSION_ARN_INVALID( + 99226, "Invalid AWS IAM role specified for the SDB.", SC_BAD_REQUEST), + + /** IAM role permissions contain duplicate entries. */ + SDB_IAM_PRINCIPAL_REPEATED( + 99227, "The IAM principal permissions contains duplicate entries.", SC_BAD_REQUEST), + + /** IAM Role account id is blank */ + AUTH_IAM_PRINCIPAL_AWS_REGION_BLANK(99228, "AWS region is malformed.", SC_BAD_REQUEST), + + /** Invalid region provided in authentication */ + AUTHENTICATION_ERROR_INVALID_REGION( + 99229, "Invalid AWS region provided during authentication.", SC_BAD_REQUEST), + + /** The token has exceeded the amount of times it can be refreshed */ + MAXIMUM_TOKEN_REFRESH_COUNT_REACHED( + 99230, "Maximum token refresh count reached, re-authentication required.", SC_FORBIDDEN), + + /** The token has exceeded the amount of times it can be refreshed */ + USER_ONLY_RESOURCE(99231, "The requested resource is for User Principals only.", SC_FORBIDDEN), + + /** KMS key is scheduled for deletion or disabled */ + KMS_KEY_IS_SCHEDULED_FOR_DELETION_OR_DISABLED( + 99232, "KMS key is scheduled for deletion or disabled", SC_INTERNAL_SERVER_ERROR), + + /** Error reading the file contents of the requested dashboard asset */ + FAILED_TO_READ_DASHBOARD_ASSET_CONTENT( + 99233, "The requested dashboard asset file could not be read", SC_NOT_FOUND), + + /** + * Various methods have switch statements for principal types if a new type gets added and the + * method doesn't get updated The method can throw this error. + */ + UNKNOWN_PRINCIPAL_TYPE( + 99234, "The method wasn't configured for the given principal type", SC_INTERNAL_SERVER_ERROR), + + /** Do not allow secure data objects/maps to be overwritten by files, or vice versa. */ + INVALID_SECURE_DATA_TYPE( + 99235, + "Failed to update secret. The new value is of a different type than the current value (e.g. file vs. key/value map).", + SC_BAD_REQUEST), + + /** One or more AWS signature headers are missing. */ + MISSING_AWS_SIGNATURE_HEADERS(99236, "One or more required headers are missing.", SC_BAD_REQUEST), + + /** + * Signature does not match. Either the request is invalid or the request is signed with invalid + * region and/or wrong host. + */ + SIGNATURE_DOES_NOT_MATCH( + 99237, + "Signature does not match. Make sure the request is signed with sts.{region}.amazonaws.com as host.", + SC_BAD_REQUEST), + + /** AWS token expired. */ + EXPIRED_AWS_TOKEN( + 99238, "The security token included in the request is expired.", SC_UNAUTHORIZED), + + /** Login failed either from incorrect email or password or from a completable future timeout. */ + LOGIN_FAILED( + 99239, "Failed to login. Please confirm email and password and try again.", SC_UNAUTHORIZED), + + /** Failed to wait for Okta Auth Completable Future to complete. */ + AUTH_RESPONSE_WAIT_FAILED( + 99240, "Failed to wait for Okta Auth Response to complete.", SC_UNAUTHORIZED), + + /** Unable to read MFA factor type because it is not one of the standard types. */ + FAILED_TO_READ_FACTOR(99241, "Failed to read Okta MFA factor type.", SC_UNAUTHORIZED), + + /** Generic authentication error for when a user attempts to login and is not successful. */ + AUTH_FAILED( + 99242, + "MFA is required. Please confirm that you are enrolled in a supported MFA device.", + SC_UNAUTHORIZED), + + /** Factor failed to validate. */ + FACTOR_VALIDATE_FAILED( + 99243, + "Failed to validate factor. Please try again or try a different factor.", + SC_UNAUTHORIZED), + + /** Generic not found error. */ + ENTITY_NOT_FOUND(99996, "Not found", SC_NOT_FOUND), + + /** Unable to fulfill request resulting in service being unavailable. */ + SERVICE_UNAVAILABLE(99997, "Service is unavailable at this time.", SC_SERVICE_UNAVAILABLE), + + /** Uncaught errors that leak. */ + INTERNAL_SERVER_ERROR(99998, "Internal server error has occurred.", SC_INTERNAL_SERVER_ERROR), + + /** Generic bad requests. This is useful because the blueprint error handling sucks. */ + GENERIC_BAD_REQUEST(99999, "Request will not be completed.", SC_BAD_REQUEST), + + /** + * If we encounter an error where something expected is not setup correctly, meaning the service + * is not functional. + */ + MISCONFIGURED_APP(99995, "The application is not properly configured.", SC_SERVICE_UNAVAILABLE), + + /** If client attempts to access resource it doesn't have access to. */ + ACCESS_DENIED(99994, "Access to the requested resource was denied.", SC_FORBIDDEN); + + private final ApiError delegate; + + DefaultApiError(final ApiError delegate) { + this.delegate = delegate; + } + + DefaultApiError(final int errorCode, final String message, final int httpStatusCode) { + this( + new ApiErrorBase( + "delegated-to-enum-wrapper-" + UUID.randomUUID().toString(), + errorCode, + message, + httpStatusCode)); + } + + @Override + public String getName() { + return this.name(); + } + + @Override + public String getErrorCode() { + return delegate.getErrorCode(); + } + + @Override + public String getMessage() { + return delegate.getMessage(); + } + + @Override + public Map getMetadata() { + return delegate.getMetadata(); + } + + @Override + public int getHttpStatusCode() { + return delegate.getHttpStatusCode(); + } +} diff --git a/cerberus-core/src/main/java/com/nike/cerberus/event/AuditableEvent.java b/cerberus-core/src/main/java/com/nike/cerberus/event/AuditableEvent.java new file mode 100644 index 000000000..319ba3719 --- /dev/null +++ b/cerberus-core/src/main/java/com/nike/cerberus/event/AuditableEvent.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.event; + +import lombok.Getter; +import org.springframework.context.ApplicationEvent; + +public class AuditableEvent extends ApplicationEvent { + + @Getter private final AuditableEventContext auditableEventContext; + + /** + * Create a new {@code ApplicationEvent}. + * + * @param source the object on which the event initially occurred or with which the event is + * associated (never {@code null}) + * @param auditableEventContext + */ + public AuditableEvent(Object source, AuditableEventContext auditableEventContext) { + super(source); + this.auditableEventContext = auditableEventContext; + } + + @Override + public String toString() { + return auditableEventContext.getEventAsString(); + } +} diff --git a/cerberus-core/src/main/java/com/nike/cerberus/event/AuditableEventContext.java b/cerberus-core/src/main/java/com/nike/cerberus/event/AuditableEventContext.java new file mode 100644 index 000000000..0a446dee8 --- /dev/null +++ b/cerberus-core/src/main/java/com/nike/cerberus/event/AuditableEventContext.java @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.event; + +import com.nike.cerberus.domain.CerberusAuthToken; +import java.io.Serializable; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Optional; +import lombok.Builder; +import lombok.Data; + +/** An event that can be used to describe what a principal is doing with the API */ +@Data +@Builder +public class AuditableEventContext implements Serializable { + + private static final long serialVersionUID = 2906382176272161512L; + public static final String UNKNOWN = "_unknown"; + + private Object principal; + private String traceId; + private String ipAddress; + private String xForwardedFor; + private String clientVersion; + private String method; + private String path; + private String action; + private String eventName; + private String originatingClass; + private String sdbNameSlug; + @Builder.Default private OffsetDateTime timestamp = OffsetDateTime.now(ZoneId.of("UTC")); + private boolean success; + private String version; + private int statusCode; + + public Optional getPrincipalAsCerberusPrincipal() { + return principal instanceof CerberusAuthToken + ? Optional.of((CerberusAuthToken) principal) + : Optional.empty(); + } + + public String getPrincipalName() { + return principal instanceof CerberusAuthToken + ? ((CerberusAuthToken) principal).getPrincipal() + : principal instanceof String + ? (String) principal + : principal != null ? principal.toString() : "Unknown"; + } + + public String getEventAsString() { + return eventName + + ", " + + "Principal: " + + getPrincipalName() + + ", " + + "Action: " + + '\'' + + action + + "\', " + + "Method: " + + method + + ", " + + "Status Code: " + + statusCode + + ", " + + "Was Success: " + + success + + ", " + + "Path: " + + path + + ", " + + "IP Address: " + + ipAddress + + ", " + + "X-Forwarded-For: " + + xForwardedFor + + ", " + + "Client Version: " + + clientVersion + + ", " + + "Cerberus Version: " + + version + + ", " + + "Originating Class: " + + originatingClass + + ", " + + "SDB Name Slug: " + + sdbNameSlug + + ", " + + "Trace ID: " + + traceId + + ", " + + "Event Timestamp: " + + timestamp.format(DateTimeFormatter.ofPattern("MMM d yyyy, hh:mm:ss a Z")); + } +} diff --git a/cerberus-core/src/main/java/com/nike/cerberus/security/CerberusPrincipal.java b/cerberus-core/src/main/java/com/nike/cerberus/security/CerberusPrincipal.java new file mode 100644 index 000000000..62f59fd79 --- /dev/null +++ b/cerberus-core/src/main/java/com/nike/cerberus/security/CerberusPrincipal.java @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.security; + +import com.google.common.collect.ImmutableSet; +import com.nike.cerberus.PrincipalType; +import com.nike.cerberus.domain.CerberusAuthToken; +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +/** + * Represents the authenticated principal. This contains the client token entity and any assigned + * roles based on that. + */ +public class CerberusPrincipal implements Authentication { + + public static final String ROLE_ADMIN = "ROLE_ADMIN"; + + public static final String ROLE_USER = "ROLE_USER"; + + public static final String METADATA_KEY_IS_ADMIN = "is_admin"; + + public static final String METADATA_KEY_GROUPS = "groups"; + + public static final String METADATA_KEY_USERNAME = "username"; + + public static final String METADATA_KEY_AWS_ACCOUNT_ID = "aws_account_id"; + + public static final String METADATA_KEY_AWS_IAM_ROLE_NAME = "aws_iam_role_name"; + + public static final String METADATA_KEY_AWS_IAM_PRINCIPAL_ARN = "aws_iam_principal_arn"; + + public static final String METADATA_KEY_IS_IAM_PRINCIPAL = "is_iam_principal"; + + public static final String METADATA_KEY_AWS_REGION = "aws_region"; + + public static final String METADATA_KEY_TOKEN_REFRESH_COUNT = "refresh_count"; + + public static final String METADATA_KEY_MAX_TOKEN_REFRESH_COUNT = "max_refresh_count"; + + private boolean authenticated = false; + + private final CerberusAuthToken cerberusAuthToken; + private final Set grantedAuthorities; + + public CerberusPrincipal(CerberusAuthToken cerberusAuthToken) { + this.cerberusAuthToken = cerberusAuthToken; + grantedAuthorities = buildGrantedAuthorities(cerberusAuthToken); + } + + private Set buildGrantedAuthorities(CerberusAuthToken cerberusAuthToken) { + final ImmutableSet.Builder roleSetBuilder = ImmutableSet.builder(); + + if (cerberusAuthToken.isAdmin()) { + roleSetBuilder.add(new SimpleGrantedAuthority(ROLE_ADMIN)); + } + + roleSetBuilder.add(new SimpleGrantedAuthority(ROLE_USER)); + + return roleSetBuilder.build(); + } + + @Override + public String getName() { + return cerberusAuthToken.getPrincipal(); + } + + public String getToken() { + return cerberusAuthToken.getToken(); + } + + public Set getUserGroups() { + if (cerberusAuthToken.getGroups() == null) { + return new HashSet<>(); + } + return new HashSet<>(Arrays.asList(cerberusAuthToken.getGroups().split(","))); + } + + public PrincipalType getPrincipalType() { + return cerberusAuthToken.getPrincipalType(); + } + + public Integer getTokenRefreshCount() { + return cerberusAuthToken.getRefreshCount(); + } + + public OffsetDateTime getTokenCreated() { + return cerberusAuthToken.getCreated(); + } + + public OffsetDateTime getTokenExpires() { + return cerberusAuthToken.getExpires(); + } + + public boolean isAdmin() { + return cerberusAuthToken.isAdmin(); + } + + @Override + public String toString() { + return String.format( + "[ Name: %s, Type: %s, Token Created: %s, Token Expires: %s, isAdmin: %s ]", + cerberusAuthToken.getPrincipal(), + cerberusAuthToken.getPrincipalType().getName(), + cerberusAuthToken + .getCreated() + .format(DateTimeFormatter.ofPattern("MMM d yyyy, hh:mm:ss a Z")), + cerberusAuthToken + .getExpires() + .format(DateTimeFormatter.ofPattern("MMM d yyyy, hh:mm:ss a Z")), + cerberusAuthToken.isAdmin()); + } + + @Override + public Collection getAuthorities() { + return grantedAuthorities; + } + + @Override + public Object getCredentials() { + return cerberusAuthToken.getToken(); + } + + @Override + public Object getDetails() { + return null; + } + + @Override + public Object getPrincipal() { + return cerberusAuthToken; + } + + @Override + public boolean isAuthenticated() { + return authenticated; + } + + @Override + public void setAuthenticated(boolean authenticated) { + this.authenticated = authenticated; + } +} diff --git a/cerberus-core/src/test/java/com/nike/cerberus/util/EnvUtils.java b/cerberus-core/src/test/java/com/nike/cerberus/util/EnvUtils.java new file mode 100644 index 000000000..5780c3b0f --- /dev/null +++ b/cerberus-core/src/test/java/com/nike/cerberus/util/EnvUtils.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.util; + +import org.apache.commons.lang3.StringUtils; + +public class EnvUtils { + private EnvUtils() {} + + public static String getRequiredEnv(String key) { + return getRequiredEnv(key, null); + } + + public static String getRequiredEnv(String key, String msg) { + String value = System.getenv(key); + if (StringUtils.isBlank(value)) { + StringBuilder sb = + (new StringBuilder("The required environment variable ")) + .append(key) + .append(" was not set or is blank."); + if (StringUtils.isNotBlank(msg)) { + sb.append(" Msg: ").append(msg); + } + + throw new IllegalStateException(sb.toString()); + } else { + return value; + } + } + + public static String getEnvWithDefault(String key, String defaultValue) { + String value = System.getenv(key); + return StringUtils.isNotBlank(value) ? value : defaultValue; + } +} diff --git a/cerberus-core/src/test/java/com/nike/cerberus/util/PropUtils.java b/cerberus-core/src/test/java/com/nike/cerberus/util/PropUtils.java new file mode 100644 index 000000000..d32c8face --- /dev/null +++ b/cerberus-core/src/test/java/com/nike/cerberus/util/PropUtils.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.util; + +import org.apache.commons.lang3.StringUtils; + +public class PropUtils { + private PropUtils() {} + + public static String getPropWithDefaultValue(String key, String defaultValue) { + return EnvUtils.getEnvWithDefault(key, System.getProperty(key, defaultValue)); + } + + public static String getRequiredProperty(String key) { + return getRequiredProperty(key, null); + } + + public static String getRequiredProperty(String key, String msg) { + String value = getPropWithDefaultValue(key, null); + if (StringUtils.isBlank(value)) { + StringBuilder sb = + (new StringBuilder("The key: ")) + .append(key) + .append( + " was not set or is blank. Check the environment variables and system properties"); + if (StringUtils.isNotBlank(msg)) { + sb.append(" Msg: ").append(msg); + } + + throw new IllegalStateException(sb.toString()); + } else { + return value; + } + } +} diff --git a/cerberus-dashboard/.gitignore b/cerberus-dashboard/.gitignore new file mode 100644 index 000000000..de4d1f007 --- /dev/null +++ b/cerberus-dashboard/.gitignore @@ -0,0 +1,2 @@ +dist +node_modules diff --git a/dashboard/README.md b/cerberus-dashboard/README.md similarity index 100% rename from dashboard/README.md rename to cerberus-dashboard/README.md diff --git a/dashboard/app/actions/appActions.js b/cerberus-dashboard/app/actions/appActions.js similarity index 86% rename from dashboard/app/actions/appActions.js rename to cerberus-dashboard/app/actions/appActions.js index 4b4e1c0de..a815d4f36 100644 --- a/dashboard/app/actions/appActions.js +++ b/cerberus-dashboard/app/actions/appActions.js @@ -1,15 +1,31 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + import React from 'react' import { hashHistory } from 'react-router' import axios from 'axios' import * as constants from '../constants/actions' import * as cms from '../constants/cms' -import * as mSDBActions from '../actions/manageSafetyDepositBoxActions' -import * as modalActions from '../actions/modalActions' +import * as mSDBActions from './manageSafetyDepositBoxActions' +import * as modalActions from './modalActions' import environmentService from 'EnvironmentService' import CreateSDBoxForm from '../components/CreateSDBoxForm/CreateSDBoxForm' -import { initCreateNewSDB } from '../actions/createSDBoxActions' +import { initCreateNewSDB } from './createSDBoxActions' import ApiError from '../components/ApiError/ApiError' -import * as messengerActions from '../actions/messengerActions' +import * as messengerActions from './messengerActions' import { getLogger } from 'logger' var log = getLogger('application-actions') @@ -149,7 +165,7 @@ export function resetToInitialState() { export function loadDashboardMetadata() { return function(dispatch) { return axios({ - url: '/dashboard/version', + url: '/info', timeout: 10000 }) .then(function (response) { @@ -175,7 +191,7 @@ export function storeDashboardMetadata(data) { return { type: constants.STORE_DASHBOARD_METADATA, payload: { - version: data.version + version: data.build.version } } -} \ No newline at end of file +} diff --git a/dashboard/app/actions/authenticationActions.js b/cerberus-dashboard/app/actions/authenticationActions.js similarity index 94% rename from dashboard/app/actions/authenticationActions.js rename to cerberus-dashboard/app/actions/authenticationActions.js index 5055682cc..3e085927c 100644 --- a/dashboard/app/actions/authenticationActions.js +++ b/cerberus-dashboard/app/actions/authenticationActions.js @@ -1,17 +1,33 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + import React from 'react' import environmentService from 'EnvironmentService' import { hashHistory } from 'react-router' import axios from 'axios' import * as constants from '../constants/actions' -import * as appActions from '../actions/appActions' -import * as messengerActions from '../actions/messengerActions' +import * as appActions from './appActions' +import * as messengerActions from './messengerActions' import * as cms from '../constants/cms' -import * as headerActions from '../actions/headerActions' +import * as headerActions from './headerActions' import * as cmsUtils from '../utils/cmsUtils' import ApiError from '../components/ApiError/ApiError' import ConfirmationBox from '../components/ConfirmationBox/ConfirmationBox' -import * as modalActions from '../actions/modalActions' -import * as manageSDBActions from '../actions/manageSafetyDepositBoxActions' +import * as modalActions from './modalActions' +import * as manageSDBActions from './manageSafetyDepositBoxActions' import * as workerTimers from 'worker-timers' import { getLogger } from 'logger' diff --git a/cerberus-dashboard/app/actions/createSDBoxActions.js b/cerberus-dashboard/app/actions/createSDBoxActions.js new file mode 100644 index 000000000..6f5361e9f --- /dev/null +++ b/cerberus-dashboard/app/actions/createSDBoxActions.js @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react' +import axios from 'axios' +import * as constants from '../constants/actions' +import * as cms from '../constants/cms' +import { hashHistory } from 'react-router' +import environmentService from 'EnvironmentService' +import * as messengerActions from './messengerActions' +import * as modalActions from './modalActions' +import * as appActions from './appActions' +import ApiError from '../components/ApiError/ApiError' +import * as humps from 'humps' + +import { getLogger } from 'logger' +var log = getLogger('create-new-sdb-actions') + +export function submitCreateNewSDB(data, token) { + + let formData = humps.decamelizeKeys(data) + + log.debug("submitting data to create sdb cms endpoint\n" + JSON.stringify(formData, null, 2)) + + return function(dispatch) { + dispatch(submittingNewSDBRequest()) + axios({ + method: 'post', + url: `${environmentService.getDomain()}${cms.BUCKET_RESOURCE}`, + headers: {'X-Cerberus-Token': token}, + data: formData, + timeout: 10 * 1000 // 10 seconds + }) + .then(function(response) { + dispatch(modalActions.popModal()) + dispatch(clearSecureData()) + dispatch(resetVersionBrowserState()) + dispatch(appActions.fetchSideBarData(token)) + hashHistory.push(`/manage-safe-deposit-box/${response.data.id}`) + }) + .catch(function (response) { + log.error('Failed to create new SDB', response) + dispatch(messengerActions.addNewMessage()) + dispatch(resetSubmittingNewSDBRequest()) + }) + } +} + +export function initCreateNewSDB(categoryId) { + return { + type: constants.CREATE_NEW_SDB_INIT, + payload: { categoryId: categoryId } + } +} + +export function submittingNewSDBRequest() { + return { + type: constants.SUBMITTING_NEW_SDB_REQUEST + } +} + +export function resetSubmittingNewSDBRequest() { + return { + type: constants.RESET_SUBMITTING_NEW_SDB_REQUEST + } +} + +export function clearSecureData() { + return { + type: constants.RESET_SDB_DATA + } +} + +export function resetVersionBrowserState() { + return { + type: constants.RESET_VERSION_BROWSER_STATE + } +} diff --git a/cerberus-dashboard/app/actions/dropdownActions.js b/cerberus-dashboard/app/actions/dropdownActions.js new file mode 100644 index 000000000..b5b193f5b --- /dev/null +++ b/cerberus-dashboard/app/actions/dropdownActions.js @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as constants from '../constants/actions' + +/** + * Event to dispatch when user mouses over username to trigger context menu to be shown + */ +export function newDropDownCreated(key, value) { + return { + type: constants.NEW_DROP_DOWN_CREATED, + payload: { + key: key, + value: value + } + } +} + +export function selectedItemClicked(key) { + return { + type: constants.SELECTED_ITEM_CLICKED, + payload: { + key: key + } + } +} \ No newline at end of file diff --git a/cerberus-dashboard/app/actions/headerActions.js b/cerberus-dashboard/app/actions/headerActions.js new file mode 100644 index 000000000..e70dee6dd --- /dev/null +++ b/cerberus-dashboard/app/actions/headerActions.js @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as constants from '../constants/actions' + +/** + * Event to dispatch when user mouses over username to trigger context menu to be shown + */ +export function mouseOverUsername() { + return { + type: constants.USERNAME_CLICKED + } +} + +/** + * Event to dispatch when user mouses over username to trigger context menu to be hidden + */ +export function mouseOutUsername() { + return { + type: constants.MOUSE_OUT_USERNAME + } +} \ No newline at end of file diff --git a/dashboard/app/actions/manageSafetyDepositBoxActions.js b/cerberus-dashboard/app/actions/manageSafetyDepositBoxActions.js similarity index 96% rename from dashboard/app/actions/manageSafetyDepositBoxActions.js rename to cerberus-dashboard/app/actions/manageSafetyDepositBoxActions.js index 13489fa1e..e358b6b9a 100644 --- a/dashboard/app/actions/manageSafetyDepositBoxActions.js +++ b/cerberus-dashboard/app/actions/manageSafetyDepositBoxActions.js @@ -1,13 +1,29 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + import React from 'react' import axios from 'axios' import environmentService from 'EnvironmentService' import * as humps from 'humps' import * as actions from '../constants/actions' import * as cms from '../constants/cms' -import * as appActions from '../actions/appActions' -import * as messengerActions from '../actions/messengerActions' +import * as appActions from './appActions' +import * as messengerActions from './messengerActions' import { hashHistory } from 'react-router' -import * as modalActions from '../actions/modalActions' +import * as modalActions from './modalActions' import ApiError from '../components/ApiError/ApiError' import ConfirmationBox from '../components/ConfirmationBox/ConfirmationBox' import downloadjs from 'downloadjs'; diff --git a/cerberus-dashboard/app/actions/messengerActions.js b/cerberus-dashboard/app/actions/messengerActions.js new file mode 100644 index 000000000..1f7509bb5 --- /dev/null +++ b/cerberus-dashboard/app/actions/messengerActions.js @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as constants from '../constants/actions' + +/** + * adds a message to the messenger + * @param message A string or JSX element to be rendered in the messenger component + */ +export function addNewMessage(message, id) { + window.scrollTo(0, 0) + return { + type: constants.ADD_MESSAGE, + payload: { + message: message, + id: id ? id : guid() + } + } +} + +export function addNewMessageWithTimeout(message, timeout) { + return (dispatch => { + let id = guid() + + dispatch(addNewMessage(message, id)) + setTimeout(() => { + dispatch(removeMessage(id)) + }, timeout) + }) +} + +export function removeMessage(id) { + return { + type: constants.REMOVE_MESSAGE, + payload: id + } +} + +export function clearAllMessages() { + return { + type: constants.CLEAR_ALL_MESSAGES + } +} + +function guid() { + function s4() { + return Math.floor((1 + Math.random()) * 0x10000) + .toString(16) + .substring(1); + } + return s4() + s4() + '-' + s4() + '-' + s4() + '-' + + s4() + '-' + s4() + s4() + s4(); +} \ No newline at end of file diff --git a/cerberus-dashboard/app/actions/metadataActions.js b/cerberus-dashboard/app/actions/metadataActions.js new file mode 100644 index 000000000..2c1655f40 --- /dev/null +++ b/cerberus-dashboard/app/actions/metadataActions.js @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as actions from '../constants/actions' +import axios from 'axios' +import ApiError from '../components/ApiError/ApiError' +import environmentService from 'EnvironmentService' +import * as cms from '../constants/cms' +import * as messengerActions from './messengerActions' +import { getLogger } from 'logger' +var log = getLogger('metadata') + +export function fetchMetadata(token, pageNumber, perPage) { + return function(dispatch) { + return axios({ + url: environmentService.getDomain() + cms.RETRIEVE_METADATA, + params: { + limit: perPage, + offset: Math.ceil(pageNumber * perPage) + }, + headers: {'X-Cerberus-Token': token}, + timeout: 10000 + }) + .then(function (response) { + let metadata = response.data + if (metadata) { + dispatch(storeMetadata(metadata)) + window.scrollTo(0, 0) + } else { + log.warn("Metadata was null or undefined") + } + }) + .catch(function (response) { + log.error('Failed to get metadata', response) + dispatch(messengerActions.addNewMessage()) + }) + } +} + +function storeMetadata(metadata) { + return { + type: actions.STORE_METADATA, + payload: { + metadata: metadata + } + } +} + +export function updatePerPage(perPage) { + return { + type: actions.UPDATE_METADATA_PER_PAGE, + payload: { + perPage: perPage + } + } +} + +export function updatePageNumber(pageNumber) { + return { + type: actions.UPDATE_METADATA_PAGE_NUMBER, + payload: { + pageNumber: pageNumber + } + } +} diff --git a/cerberus-dashboard/app/actions/modalActions.js b/cerberus-dashboard/app/actions/modalActions.js new file mode 100644 index 000000000..02d4434c4 --- /dev/null +++ b/cerberus-dashboard/app/actions/modalActions.js @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as actions from '../constants/actions' +import { getLogger } from 'logger' +var log = getLogger('modal') + + +export function pushModal(modalComponent) { + return { + type: actions.PUSH_MODAL, + payload: { + modalComponent: modalComponent + } + } +} + +export function popModal() { + return { + type: actions.POP_MODAL + } +} + +export function clearAllModals() { + return { + type: actions.CLEAR_ALL_MODALS + } +} \ No newline at end of file diff --git a/dashboard/app/actions/versionHistoryBrowserActions.js b/cerberus-dashboard/app/actions/versionHistoryBrowserActions.js similarity index 88% rename from dashboard/app/actions/versionHistoryBrowserActions.js rename to cerberus-dashboard/app/actions/versionHistoryBrowserActions.js index c4f54e6b3..bb8293971 100644 --- a/dashboard/app/actions/versionHistoryBrowserActions.js +++ b/cerberus-dashboard/app/actions/versionHistoryBrowserActions.js @@ -1,7 +1,23 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + import React from 'react' import axios from 'axios' import * as actions from '../constants/actions' -import * as messengerActions from '../actions/messengerActions' +import * as messengerActions from './messengerActions' import ApiError from '../components/ApiError/ApiError' import downloadjs from "downloadjs"; @@ -167,4 +183,4 @@ export function updateVersionedSecureDataForPath(versionId, secureData, type) { secureData: secureData } } -} \ No newline at end of file +} diff --git a/dashboard/app/assets/images/add-dark-grey.png b/cerberus-dashboard/app/assets/images/add-dark-grey.png similarity index 100% rename from dashboard/app/assets/images/add-dark-grey.png rename to cerberus-dashboard/app/assets/images/add-dark-grey.png diff --git a/dashboard/app/assets/images/add-dark-grey.svg b/cerberus-dashboard/app/assets/images/add-dark-grey.svg similarity index 100% rename from dashboard/app/assets/images/add-dark-grey.svg rename to cerberus-dashboard/app/assets/images/add-dark-grey.svg diff --git a/dashboard/app/assets/images/add-green.png b/cerberus-dashboard/app/assets/images/add-green.png similarity index 100% rename from dashboard/app/assets/images/add-green.png rename to cerberus-dashboard/app/assets/images/add-green.png diff --git a/dashboard/app/assets/images/add-green.svg b/cerberus-dashboard/app/assets/images/add-green.svg similarity index 100% rename from dashboard/app/assets/images/add-green.svg rename to cerberus-dashboard/app/assets/images/add-green.svg diff --git a/dashboard/app/assets/images/cerberus-logo-head-dark-grey.png b/cerberus-dashboard/app/assets/images/cerberus-logo-head-dark-grey.png similarity index 100% rename from dashboard/app/assets/images/cerberus-logo-head-dark-grey.png rename to cerberus-dashboard/app/assets/images/cerberus-logo-head-dark-grey.png diff --git a/dashboard/app/assets/images/cerberus-logo-head-dark-grey.svg b/cerberus-dashboard/app/assets/images/cerberus-logo-head-dark-grey.svg similarity index 100% rename from dashboard/app/assets/images/cerberus-logo-head-dark-grey.svg rename to cerberus-dashboard/app/assets/images/cerberus-logo-head-dark-grey.svg diff --git a/dashboard/app/assets/images/cerberus-logo-head-off-white.svg b/cerberus-dashboard/app/assets/images/cerberus-logo-head-off-white.svg similarity index 100% rename from dashboard/app/assets/images/cerberus-logo-head-off-white.svg rename to cerberus-dashboard/app/assets/images/cerberus-logo-head-off-white.svg diff --git a/dashboard/app/assets/images/cerberus-logo-narrow-dark-grey.png b/cerberus-dashboard/app/assets/images/cerberus-logo-narrow-dark-grey.png similarity index 100% rename from dashboard/app/assets/images/cerberus-logo-narrow-dark-grey.png rename to cerberus-dashboard/app/assets/images/cerberus-logo-narrow-dark-grey.png diff --git a/dashboard/app/assets/images/cerberus-logo-narrow-dark-grey.svg b/cerberus-dashboard/app/assets/images/cerberus-logo-narrow-dark-grey.svg similarity index 100% rename from dashboard/app/assets/images/cerberus-logo-narrow-dark-grey.svg rename to cerberus-dashboard/app/assets/images/cerberus-logo-narrow-dark-grey.svg diff --git a/dashboard/app/assets/images/cerberus-logo-narrow-off-white.png b/cerberus-dashboard/app/assets/images/cerberus-logo-narrow-off-white.png similarity index 100% rename from dashboard/app/assets/images/cerberus-logo-narrow-off-white.png rename to cerberus-dashboard/app/assets/images/cerberus-logo-narrow-off-white.png diff --git a/dashboard/app/assets/images/cerberus-logo-narrow-off-white.svg b/cerberus-dashboard/app/assets/images/cerberus-logo-narrow-off-white.svg similarity index 100% rename from dashboard/app/assets/images/cerberus-logo-narrow-off-white.svg rename to cerberus-dashboard/app/assets/images/cerberus-logo-narrow-off-white.svg diff --git a/dashboard/app/assets/images/cheveron-down.svg b/cerberus-dashboard/app/assets/images/cheveron-down.svg similarity index 100% rename from dashboard/app/assets/images/cheveron-down.svg rename to cerberus-dashboard/app/assets/images/cheveron-down.svg diff --git a/dashboard/app/assets/images/cheveron-right.svg b/cerberus-dashboard/app/assets/images/cheveron-right.svg similarity index 100% rename from dashboard/app/assets/images/cheveron-right.svg rename to cerberus-dashboard/app/assets/images/cheveron-right.svg diff --git a/dashboard/app/assets/images/copy.ai b/cerberus-dashboard/app/assets/images/copy.ai similarity index 100% rename from dashboard/app/assets/images/copy.ai rename to cerberus-dashboard/app/assets/images/copy.ai diff --git a/dashboard/app/assets/images/copy.svg b/cerberus-dashboard/app/assets/images/copy.svg similarity index 100% rename from dashboard/app/assets/images/copy.svg rename to cerberus-dashboard/app/assets/images/copy.svg diff --git a/dashboard/app/assets/images/edit-dark-grey.svg b/cerberus-dashboard/app/assets/images/edit-dark-grey.svg similarity index 100% rename from dashboard/app/assets/images/edit-dark-grey.svg rename to cerberus-dashboard/app/assets/images/edit-dark-grey.svg diff --git a/dashboard/app/assets/images/edit.ai b/cerberus-dashboard/app/assets/images/edit.ai similarity index 100% rename from dashboard/app/assets/images/edit.ai rename to cerberus-dashboard/app/assets/images/edit.ai diff --git a/dashboard/app/assets/images/eye-icons.ai b/cerberus-dashboard/app/assets/images/eye-icons.ai similarity index 100% rename from dashboard/app/assets/images/eye-icons.ai rename to cerberus-dashboard/app/assets/images/eye-icons.ai diff --git a/dashboard/app/assets/images/eye.svg b/cerberus-dashboard/app/assets/images/eye.svg similarity index 100% rename from dashboard/app/assets/images/eye.svg rename to cerberus-dashboard/app/assets/images/eye.svg diff --git a/dashboard/app/assets/images/eye_closed.svg b/cerberus-dashboard/app/assets/images/eye_closed.svg similarity index 100% rename from dashboard/app/assets/images/eye_closed.svg rename to cerberus-dashboard/app/assets/images/eye_closed.svg diff --git a/dashboard/app/assets/images/facivon.ico b/cerberus-dashboard/app/assets/images/facivon.ico similarity index 100% rename from dashboard/app/assets/images/facivon.ico rename to cerberus-dashboard/app/assets/images/facivon.ico diff --git a/dashboard/app/assets/images/folder-dark-grey.svg b/cerberus-dashboard/app/assets/images/folder-dark-grey.svg similarity index 100% rename from dashboard/app/assets/images/folder-dark-grey.svg rename to cerberus-dashboard/app/assets/images/folder-dark-grey.svg diff --git a/dashboard/app/assets/images/key-dark-grey.svg b/cerberus-dashboard/app/assets/images/key-dark-grey.svg similarity index 100% rename from dashboard/app/assets/images/key-dark-grey.svg rename to cerberus-dashboard/app/assets/images/key-dark-grey.svg diff --git a/dashboard/app/assets/images/lock-closed-dark-grey.svg b/cerberus-dashboard/app/assets/images/lock-closed-dark-grey.svg similarity index 100% rename from dashboard/app/assets/images/lock-closed-dark-grey.svg rename to cerberus-dashboard/app/assets/images/lock-closed-dark-grey.svg diff --git a/dashboard/app/assets/images/lock-open-dark-grey.svg b/cerberus-dashboard/app/assets/images/lock-open-dark-grey.svg similarity index 100% rename from dashboard/app/assets/images/lock-open-dark-grey.svg rename to cerberus-dashboard/app/assets/images/lock-open-dark-grey.svg diff --git a/dashboard/app/assets/images/refresh-split.png b/cerberus-dashboard/app/assets/images/refresh-split.png similarity index 100% rename from dashboard/app/assets/images/refresh-split.png rename to cerberus-dashboard/app/assets/images/refresh-split.png diff --git a/dashboard/app/assets/images/refresh-split.svg b/cerberus-dashboard/app/assets/images/refresh-split.svg similarity index 100% rename from dashboard/app/assets/images/refresh-split.svg rename to cerberus-dashboard/app/assets/images/refresh-split.svg diff --git a/dashboard/app/assets/images/refresh.png b/cerberus-dashboard/app/assets/images/refresh.png similarity index 100% rename from dashboard/app/assets/images/refresh.png rename to cerberus-dashboard/app/assets/images/refresh.png diff --git a/dashboard/app/assets/images/refresh.svg b/cerberus-dashboard/app/assets/images/refresh.svg similarity index 100% rename from dashboard/app/assets/images/refresh.svg rename to cerberus-dashboard/app/assets/images/refresh.svg diff --git a/dashboard/app/assets/images/remove-dark-grey.png b/cerberus-dashboard/app/assets/images/remove-dark-grey.png similarity index 100% rename from dashboard/app/assets/images/remove-dark-grey.png rename to cerberus-dashboard/app/assets/images/remove-dark-grey.png diff --git a/dashboard/app/assets/images/remove-dark-grey.svg b/cerberus-dashboard/app/assets/images/remove-dark-grey.svg similarity index 100% rename from dashboard/app/assets/images/remove-dark-grey.svg rename to cerberus-dashboard/app/assets/images/remove-dark-grey.svg diff --git a/dashboard/app/assets/images/remove-red.png b/cerberus-dashboard/app/assets/images/remove-red.png similarity index 100% rename from dashboard/app/assets/images/remove-red.png rename to cerberus-dashboard/app/assets/images/remove-red.png diff --git a/dashboard/app/assets/images/remove-red.svg b/cerberus-dashboard/app/assets/images/remove-red.svg similarity index 100% rename from dashboard/app/assets/images/remove-red.svg rename to cerberus-dashboard/app/assets/images/remove-red.svg diff --git a/dashboard/app/assets/mockups/Manage SDB Mock Up.ai b/cerberus-dashboard/app/assets/mockups/Manage SDB Mock Up.ai similarity index 100% rename from dashboard/app/assets/mockups/Manage SDB Mock Up.ai rename to cerberus-dashboard/app/assets/mockups/Manage SDB Mock Up.ai diff --git a/dashboard/app/assets/mockups/Manage SDB Mock Up.svg b/cerberus-dashboard/app/assets/mockups/Manage SDB Mock Up.svg similarity index 100% rename from dashboard/app/assets/mockups/Manage SDB Mock Up.svg rename to cerberus-dashboard/app/assets/mockups/Manage SDB Mock Up.svg diff --git a/dashboard/app/assets/styles/common.scss b/cerberus-dashboard/app/assets/styles/common.scss similarity index 88% rename from dashboard/app/assets/styles/common.scss rename to cerberus-dashboard/app/assets/styles/common.scss index 5939056b7..02ab0aae9 100644 --- a/dashboard/app/assets/styles/common.scss +++ b/cerberus-dashboard/app/assets/styles/common.scss @@ -1,3 +1,19 @@ +/*! + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + $snkrs_black: #111; $snkrs_accent: #FF841F; $snkrs_white: #FFF; diff --git a/dashboard/app/assets/styles/reactSelect.scss b/cerberus-dashboard/app/assets/styles/reactSelect.scss similarity index 92% rename from dashboard/app/assets/styles/reactSelect.scss rename to cerberus-dashboard/app/assets/styles/reactSelect.scss index de4b0bead..d418c5e6f 100644 --- a/dashboard/app/assets/styles/reactSelect.scss +++ b/cerberus-dashboard/app/assets/styles/reactSelect.scss @@ -1,4 +1,20 @@ -@import './common.scss'; +/*! + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@import 'common'; .Select { position: relative; @@ -387,4 +403,4 @@ to { -webkit-transform: rotate(1turn); } -} \ No newline at end of file +} diff --git a/cerberus-dashboard/app/components/AddButton/AddButton.js b/cerberus-dashboard/app/components/AddButton/AddButton.js new file mode 100644 index 000000000..a823a59b9 --- /dev/null +++ b/cerberus-dashboard/app/components/AddButton/AddButton.js @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react' +import { Component } from 'react' +import PropTypes from 'prop-types' +import './AddButton.scss' +import '../../assets/images/add-green.svg' + +/** + * Component for an Add Button (a button with a plus and a message) + */ +export default class AddButton extends Component { + static propTypes = { + handleClick: PropTypes.func.isRequired, + message: PropTypes.string.isRequired + } + + render() { + const {handleClick, message} = this.props + return ( +

{ + handleClick() + }}> +
+
{message}
+
+ ) + } +} diff --git a/cerberus-dashboard/app/components/AddButton/AddButton.scss b/cerberus-dashboard/app/components/AddButton/AddButton.scss new file mode 100644 index 000000000..c8a615454 --- /dev/null +++ b/cerberus-dashboard/app/components/AddButton/AddButton.scss @@ -0,0 +1,29 @@ +/*! + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.permissions-add-new-permission-button-container { + cursor: pointer; + display: flex; + padding-left: 1px; + width: 250px; + + .permissions-add-new-permission-add-icon { + padding-left: 10px; + } + .permissions-add-new-permission-add-label { + padding-left: 7px; + } +} \ No newline at end of file diff --git a/cerberus-dashboard/app/components/ApiError/ApiError.js b/cerberus-dashboard/app/components/ApiError/ApiError.js new file mode 100644 index 000000000..f83f60178 --- /dev/null +++ b/cerberus-dashboard/app/components/ApiError/ApiError.js @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react' +import { Component } from 'react' +import PropTypes from 'prop-types' +import * as cmsUtils from '../../utils/cmsUtils' +import './ApiError.scss' + +/** + * A component to use to make API messages sent to the messenger look pretty and be html and stylable + * + * @prop message The Message for this app to provide context to the user for what action failed + * @prop response The Axios response + */ +export default class ApiError extends Component { + static propTypes = { + message: PropTypes.string.isRequired, + response: PropTypes.object.isRequired + } + + render() { + const {message, response} = this.props + return ( +
+
An API error has occurred
+
{message}
+
+
+
Server Status:
+
{response.status}, {response.statusText}
+
+
+
Server Message:
+
{cmsUtils.parseCMSError(response)}
+
+
+
+ ) + } +} \ No newline at end of file diff --git a/cerberus-dashboard/app/components/ApiError/ApiError.scss b/cerberus-dashboard/app/components/ApiError/ApiError.scss new file mode 100644 index 000000000..bb3485710 --- /dev/null +++ b/cerberus-dashboard/app/components/ApiError/ApiError.scss @@ -0,0 +1,60 @@ +/*! + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@import '../../assets/styles/common'; + +.api-error-wrapper { + + .api-error-header { + line-height: 20px; + font-weight: bold; + font-size: 20px; + color: $snkrs_error; + } + + .api-error-message { + font-size: 16px; + } + + .api-error-server-provided-details { + + .status-wrapper { + display: flex; + flex-flow: row; + + .api-error-server-status-label { + font-weight: bold; + } + + .api-error-server-status { + padding-left: 10px; + } + } + + .server-message-wrapper { + display: flex; + flex-flow: row; + + .api-error-server-message-label { + font-weight: bold; + } + + .api-error-server-message { + padding-left: 10px; + } + } + } +} diff --git a/dashboard/app/components/App/App.js b/cerberus-dashboard/app/components/App/App.js similarity index 81% rename from dashboard/app/components/App/App.js rename to cerberus-dashboard/app/components/App/App.js index ccca95943..08788c109 100644 --- a/dashboard/app/components/App/App.js +++ b/cerberus-dashboard/app/components/App/App.js @@ -1,3 +1,19 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + import React from 'react' import { Component } from 'react' import { connect } from 'react-redux' diff --git a/cerberus-dashboard/app/components/App/App.scss b/cerberus-dashboard/app/components/App/App.scss new file mode 100644 index 000000000..91f0f8dc3 --- /dev/null +++ b/cerberus-dashboard/app/components/App/App.scss @@ -0,0 +1,62 @@ +/*! + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@import '../../assets/styles/common'; + +html, body { + height: 100%; +} + +#main-wrapper { + height: 100%; + overflow-y: hidden; +} + +#content-wrapper { + display: flex; + flex-direction: column; + background-color: $snkrs_white; + height: 100%; +} + +#content { + align-content: stretch; + flex: 1 0 auto; + display: flex; + background-color: $snkrs_white; +} + +#workspace { + width: 75%; + flex: 1; + align-content: stretch; + background-color: $snkrs_white; + + border-color: $snkrs_light_grey; + border-left-style: solid; + border-left-width: 1px; + + max-height: 90vh; + overflow-y: auto; + + #workspace-wrapper { + display: flex; + + ul { + padding-left: 10px; + } + } +} diff --git a/cerberus-dashboard/app/components/Buttons/Buttons.js b/cerberus-dashboard/app/components/Buttons/Buttons.js new file mode 100644 index 000000000..8c923e42c --- /dev/null +++ b/cerberus-dashboard/app/components/Buttons/Buttons.js @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react' +import { Component } from 'react' +import PropTypes from 'prop-types' +import './Buttons.scss' +import '../../assets/images/remove-red.svg' + +/** + * Dumb Component to display the buttons for a permissions row. + * + * @param index value to be passed to handleRemoveClicked when the remove button + * @param handleRemoveClicked the function that will be called when the remove button is clicked + */ +// TODO make this be REMOVE button and generify for all components to be able to use +export default class PermissionButtons extends Component { + static propTypes = { + handleRemoveClicked: PropTypes.func.isRequired + } + + render() { + const {handleRemoveClicked} = this.props + + return( +
+
+
+ ) + } +} \ No newline at end of file diff --git a/cerberus-dashboard/app/components/Buttons/Buttons.scss b/cerberus-dashboard/app/components/Buttons/Buttons.scss new file mode 100644 index 000000000..81889cc45 --- /dev/null +++ b/cerberus-dashboard/app/components/Buttons/Buttons.scss @@ -0,0 +1,29 @@ +/*! + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.permissions-row-buttons { + padding-left: 5px; + padding-right: 5px; + +.permission-remove { + background: url(../../assets/images/remove-red.svg); + margin-top: 11px; + margin-left: 3px; + padding: 8px; + cursor: pointer; + background-repeat: no-repeat; +} +} \ No newline at end of file diff --git a/cerberus-dashboard/app/components/CategorySelect/CategorySelect.js b/cerberus-dashboard/app/components/CategorySelect/CategorySelect.js new file mode 100644 index 000000000..6572a8aca --- /dev/null +++ b/cerberus-dashboard/app/components/CategorySelect/CategorySelect.js @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react' +import { Component } from 'react' +import Select from 'react-select' +import './CategorySelect.scss' + +export default class CategorySelect extends Component { + + render() { + const {categories, value, onChange, handleBeingTouched, touched, error} = this.props + + var options = categories.map(function(category) { + return {label: category.display_name, value: category.id} + }) + + return ( +
+ + { + this.props.dispatch(setSelectedDeviceId(selectedFactor.value)) + handleBeingTouched(); onChange(selectedFactor)} + } + onBlur={() => { handleBeingTouched() }} + value={value} + placeholder="Select a MFA device" + options={options} /> + {touched && error &&
{error}
} +
+ ) + } +} \ No newline at end of file diff --git a/cerberus-dashboard/app/components/Modal/Modal.js b/cerberus-dashboard/app/components/Modal/Modal.js new file mode 100644 index 000000000..449f0ed45 --- /dev/null +++ b/cerberus-dashboard/app/components/Modal/Modal.js @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react' +import { Component } from 'react' +import PropTypes from 'prop-types' + +import './Modal.scss' + +import { getLogger } from 'logger' +var log = getLogger('modal') + +export default class Modal extends Component { + + static propTypes = { + modalStack: PropTypes.array.isRequired + } + + render() { + const {modalStack} = this.props + + if (modalStack.length < 1) { + return(
) + } + + let modal = modalStack[modalStack.length - 1] + + return ( +
+
+ {modal} +
+
+ ) + } +} \ No newline at end of file diff --git a/cerberus-dashboard/app/components/Modal/Modal.scss b/cerberus-dashboard/app/components/Modal/Modal.scss new file mode 100644 index 000000000..2f3485b2d --- /dev/null +++ b/cerberus-dashboard/app/components/Modal/Modal.scss @@ -0,0 +1,42 @@ +/*! + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@import '../../assets/styles/common'; + +.modal-container { + position: absolute; + top: 0; + left: 0; + padding-top: 25px; + padding-bottom: 25px; + + background-color: rgba(51,51,51, .85); + + display: flex; + align-items: center; + justify-content: center; + width: 100%; + min-height: 100%; + + z-index: 100000; + + .modal-component-wrapper { + border-radius: 5px; + padding: 25px; + background-color: $snkrs_off_white; + } + +} diff --git a/cerberus-dashboard/app/components/NotFound/NotFound.js b/cerberus-dashboard/app/components/NotFound/NotFound.js new file mode 100644 index 000000000..5dc21ae9e --- /dev/null +++ b/cerberus-dashboard/app/components/NotFound/NotFound.js @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react' +import { Component } from 'react' +import { hashHistory } from 'react-router' +import { connect } from 'react-redux' + +@connect() +export default class NotFound extends Component { + componentDidMount() { + hashHistory.push('/') + } + + render() { + return

Not found

+ } +} \ No newline at end of file diff --git a/cerberus-dashboard/app/components/RoleSelect/RoleSelect.js b/cerberus-dashboard/app/components/RoleSelect/RoleSelect.js new file mode 100644 index 000000000..0f39fb727 --- /dev/null +++ b/cerberus-dashboard/app/components/RoleSelect/RoleSelect.js @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2020 Nike, inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react' +import { Component } from 'react' +import Select from 'react-select' + +export default class RoleSelect extends Component { + + render() { + const {roles, value, onChange, handleBeingTouched, touched, error} = this.props + + var options = roles.map(function(permission) { + let option = {label: permission.name, value: permission.id} + return option + }) + + return ( +
+