diff --git a/.dockerignore b/.dockerignore index 0ece2222fb..edebb4371c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,16 +12,20 @@ cucumber *.deb .git +.idea engines/conjur_audit/spec/dummy/log coverage demo dev docker +gems/slosilo/Gemfile.lock +gems/slosilo/spec/reports log package run spec/reports spec/reports-audit + tmp # Ignore directories that are only relevant in gh diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7219cbfa0d..7490c75ca3 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,10 +1 @@ -* @cyberark/conjur-core-team @conjurinc/conjur-core-team @conjurdemos/conjur-core-team - -# Changes to .trivyignore require Security Architect approval -.trivyignore @cyberark/security-architects @conjurinc/security-architects @conjurdemos/security-architects - -# Changes to .codeclimate.yml require Quality Architect approval -.codeclimate.yml @cyberark/quality-architects @conjurinc/quality-architects @conjurdemos/quality-architects - -# Changes to SECURITY.md require Security Architect approval -SECURITY.md @cyberark/security-architects @conjurinc/security-architects @conjurdemos/security-architects +* @cyberark/ConjurCloud diff --git a/.gitignore b/.gitignore index ede6e0d417..f8830b1512 100644 --- a/.gitignore +++ b/.gitignore @@ -73,4 +73,31 @@ conjur_git_commit # AuthnOIDC V2 w/ Identity setup dev/policies/authenticators/authn-oidc/identity-users.yml +gem/slosilo/*.gem +gem/slosilo/*.rbc +gem/slosilo/.bundle +gem/slosilo/.yardoc +gem/slosilo/InstalledFiles +gem/slosilo/_yardoc +gem/slosilo/coverage +gem/slosilo/doc/ +gem/slosilo/lib/bundler/man +gem/slosilo/pkg +gem/slosilo/rdoc +gem/slosilo/spec/reports +gem/slosilo/test/tmp +gem/slosilo/test/version_tmp +gem/slosilo/tmp +gem/slosilo/.rvmrc +gem/slosilo/.project +gem/slosilo/.kateproject.d +gem/slosilo/.idea + +# Rufus scheduler lock file +.slosilo-rotation-rufus-scheduler.lock + +# Ignore localstack's cache +dev/localstack/cache/** + + VERSION diff --git a/.trivyignore b/.trivyignore index afd9319e23..e69de29bb2 100644 --- a/.trivyignore +++ b/.trivyignore @@ -1,93 +0,0 @@ -# OpenSSL CVEs -# -# Because of the way OpenSSL 1.0.2 has moved to premium support and our Ubuntu -# base image, trivy flags a number of OpenSSL issues in Conjur because the fix -# for most Ubuntu users is to move to 1.1.1 instead of having the continued support -# in the 1.0.2 line. Additionally, trivy flages 1.0.2zf as vulnerable to issues that -# only affect 1.1.x. As of the time of this writing, we use 1.0.2zf which either -# has the fix or is unaffected by these issues. -CVE-2022-2097 -CVE-2022-2068 -CVE-2022-1292 -CVE-2022-0778 -CVE-2021-23841 -CVE-2021-23840 -CVE-2021-3712 -CVE-2019-1563 -CVE-2019-1551 -CVE-2019-1549 -CVE-2019-1547 -CVE-2018-0735 -CVE-2018-0734 - -# NULL pointer deref. OpenSSL 1.0.2 is not impacted -CVE-2021-3449 - -# We already use a later version than the ones listed as impacted by this -# CVE, so we believe this is just a scanner issue. -CVE-2014-7819 - -# Rake vulnerability for versions < 12.3.3. The version of Rake used by Conjur -# has been updated to 13.0.1. Some of the Conjur dependencies still declare a -# vulnerable version of Rake in their development dependencies, but do not pose -# a risk to Conjur. -CVE-2020-8130 - -# Applications that call the SSL_check_chain() function during or after a TLS 1.3 handshake -# may crash due to a NULL pointer dereference as a result of incorrect handling of the "signature_algorithms_cert" -# TLS extension. this issue was fixed in OpenSSL 1.1.1g -# -# In order to support fips with openssl we are required to downgrading openssl version to 1.0.2 until openssl will -# support fips module in newer versions -# This vulnerability this is not relevant to us as -# 1. The installed version (1.0.2u) does not support 1.3 -# 2. Trivy detect the usage of openssl 1.0.2 (can be reproduced with -# docker run -v /var/run/docker.sock:/var/run/docker.sock -# -v $(PWD):/workspace --rm aquasec/trivy -f json -o /workspace/scan_results-conjur-unfixed.json --no-progress -# --ignorefile .trivyignore registry.tld/ruby-fips-base-image-phusion:1.0.0) -# -# Performed by @yahalomk approved by @shaharglazner -CVE-2020-1967 - -# CVE-2020-1971 -# The X.509 GeneralName type is a generic type for representing different types -# of names. One of those name types is known as EDIPartyName. OpenSSL provides a -# function GENERAL_NAME_cmp which compares different instances of a GENERAL_NAME -# to see if they are equal or not. This function behaves incorrectly when both -# GENERAL_NAMEs contain an EDIPARTYNAME. A NULL pointer dereference and a crash -# may occur leading to a possible denial of service attack. -# OpenSSL itself uses the GENERAL_NAME_cmp function for two purposes: -# -# 1) Comparing CRL distribution point names between an available CRL and a CRL -# distribution point embedded in an X509 certificate. -# -# 2) When verifying that a timestamp response token signer matches the timestamp -# authority name (exposed via the API functions TS_RESP_verify_response and -# TS_RESP_verify_token) If an attacker can control both items being compared -# then that attacker could trigger a crash. -# -# All OpenSSL 1.1.1 and 1.0.2 versions are affected by this issue. Fixed in OpenSSL -# 1.1.1i (Affected 1.1.1-1.1.1h). Fixed in OpenSSL 1.0.2x (Affected 1.0.2-1.0.2w). -# -# In order to support FIPS with OpenSSL we are required to use OpenSSL version -# 1.0.2 until OpenSSL supports the FIPS module in newer versions. The latest -# available version to us is 1.0.2u, which does not include this fix. -# -# We've determined that we are not impacted by this vulnerability because: -# - we do not directly perform CRL checks in the Conjur or DAP software -# - we do not enable automatic CRL checks in openssl tools -# - we do not call any of the impacted OpenSSL APIs or any of the APIs that expose -# impacted behavior. -# -# Performed by @micahlee, approved by @andytinkham -CVE-2020-1971 - -# CVE-2021-3711 -# The vulnerability is not affected Conjur's version of OpenSSL 1.0.2u (https://www.openssl.org/news/secadv/20210824.txt) -# Conjur does not use SM2 algorithm (https://www.openssl.org/docs/manmaster/man7/SM2.html) -CVE-2021-3711 - -# We have the fix for CVE-2023-0286 in openssl 1.0.2zg, but because OpenSSL 1.0.2 -# is only available in premium support, trivy thinks we should use something in the 1.1.1 -# line. We can't, due to FIPS compliance, so need to continue to ignore this issue. -CVE-2023-0286 diff --git a/API_VERSION b/API_VERSION index 03f488b076..c7cb1311a6 100644 --- a/API_VERSION +++ b/API_VERSION @@ -1 +1 @@ -5.3.0 +5.3.1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 23a48a6f01..4282cd7189 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,465 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Nothing should go in this section, please add to the latest unreleased version (and update the corresponding date), or add a new version. -## [1.19.5] - 2023-05-16 +## [1.0.40-cloud] - 2024-09-29 +### Added +- Cache role membership in edge replication for Atlantis +- Added scaffolds for add and remove permission event +- Added scaffolds for create/delete secret event + +### Fixed +- Fixed assume role when credentials expired + +## [1.0.39-cloud] - 2024-09-22 +### Added +- changing status controller to work without transaction +- adding closing timeout for unused DB Connections +- Adding requests telemetry + +## [1.0.38-cloud] - 2024-09-15 +### Added +- conjur sync version 13.3 - part 3 (continues from 1.0.36v). Includes: + - Policy Factories CNJR-3859 + - Authenticator Refactor CNJR-3264 +- switch OIDC tests to use @skip with yaml instead of Before hook + +### Fixed +- Fixed pubsub scheduler to not hit CPU, removed rake and calling the code itself +- Fixed debug log sent DB queries, change debug logs in conjur to logger.debug{} +- Change the way to fetch Edge attribute name from .name to [:name] +- Failed authentication requests now return no body, only and error code. + ONYX-60466 +- Failed cli users authentication fix ONYX-60467 + +## [1.0.36-cloud] - 2024-09-01 +### Added +- Restrict grant only to appropriate types +- conjur sync version 13.3 - part 2 (continues from 1.0.29v) + +### Changed +- Consume Pubsub configurations from CM + +## [1.0.35-cloud] - 2024-08-25 +### Added +- Integration with Configuration Manager (disabled by default) +- Added sending messages to sns topic +- Added scheduler to send messages to sns topic + +## [1.0.34-cloud] - 2024-08-11 +### Fixed +- Improve policy load performance (Cherry Pick form conjur-OSS) +- Improve is_allowed_to DB query (Cherry Pick form conjur-OSS) + +## [1.0.33-cloud] - 2024-07-28 +### Fixed +- Existing test in the middle (Cherry Pick form conjur-OSS) + +### Security +- Stop printing puma's stacktrace CONCLOUDESE-249 + +### Added +- Events table for PubSub +- Secret Service + +## [1.0.32-cloud] - 2024-07-14 +### Fixed +- Fixed return 403 when 404 suppose to return. ONYX-58244 +- Fixed sort on empty issuers list. ONYX-58767 + +### Changed +- Return timestamp in Edge API. ONYX-58711 + +## [1.0.31-cloud] - 2024-07-07 +### Changed +- Add ':' for allowed chars in workload name for workload create API +- From now on, issuer's TTL must be between 900 to 43200 seconds +- change minimal flag to projection flag in get issuer api + +### Security +- Upgraded Rails to 6.1.7.8, to resolve CVE-2023-22796 +- Upgrade Nokogiri, actionpack, json-jwt to to solve vulnerabilities + +### Fixed +- Fix rake task cleaning orphan resources. CONJSE-1875 + +## [1.0.30-cloud] - 2024-06-30 +### Added +- Edge agents can now fetch single secret with secrets API +- Edge agents can now replicate a specific secret + +### Changed +- In the case that an issuer access key id is updated the API requires the secret to change as well + +### Fixed +- Fixed orphaned roles when deleting policy resources. CONJSE-1875 + +## [1.0.29-cloud] - 2024-06-09 +### Added +- Issuer API have telemetry counting API calls + +### Changed +- Update issuer can now update TTL or data field as seperate request +- Issuer API will not return secret_access_key anymore, instead they will return '*****' +- First part of the sync with conjur enterprise V13.3 includes: + - support for arm64 + - cli version 8 + - Update authn_k8s test server fixture file names + - [SECURITY] Upgrade rack to 2.2.8.1 and Upgrade puma to 6.4.2 + +## [1.0.28-cloud] - 2024-06-02 +### Changed +- Updated first PCloud fetch telemetry +- Remove warning on updating DB outside of policy as its needed for v2 apis (20240526095700_policy_log_remove_error_on_out_of_policy_change.rb) + +## [1.0.27-cloud] - 2024-05-26 +### Added +- Redis (disabled) with support for: user, resource (secret) +- Adding PAM SH telemetry +- Adding Env dimension to telemetry metrics + +### Changed +- Removed update permission as allowed permission for dynamic secret +- Change return value for dynamic secret when issuer not found + +### Fixed +- Fix security issue - edge max limit exceed on concurrent requests [CONCLOUDSE-246](https://ca-il-jira.il.cyber-ark.com:8443/browse/CONCLOUDSE-246) + +## [1.0.26-cloud] - 2024-05-12 +### Changed +- Text review for dynamic secrets + +## [1.0.25-cloud] - 2024-04-21 +### Added +- Return permissions in static secret +- Get dynamic secret +- Create synchronizer host endpoint +- Generate synchronizer intaller token endpoint +- Info for tenant endpoint +- Adding telemetry support for ConjurCloud +- Redis (disabled) with support for: get, batch, version, encryption, deletion, v2-update +- API agents edge info (replace agent validate-permission) +- Redis (disabled) support for slosilo key rotation and fetch. + +### Removed +- API agent validate-permission + +## [1.0.24-cloud] - 2024-03-24 +### Changed +- Replace ephemerals to dynamic for dynamic secrets + +### Added +- API for replacing dynamic secret + +## [1.0.23-cloud] - 2024-03-17 +### Added +- API for creating ephemeral secret +- Selective replication for secrets +- API for replacing static secret + +### Fixed +- Fixed internal server error for issuer consisting of digits only + +### Changed +- Reverse default for delete issuer's variables to true unless specified using keep_secrets param + +## [1.0.22-cloud] - 2024-02-25 +### Added +- The Kubernetes authenticator now supports the + [status](https://docs.conjur.org/Latest/en/Content/Developer/Conjur_API_authenticator_status.htm) + API for verifying authenticator configuration. + [Conjur-Enterprise/conjur#48](https://github.cyberng.com/Conjur-Enterprise/conjur/pull/48) +- The Kubernetes authenticator status now verifies that the Kubernetes API + CA certificate has not expired. + [Conjur-Enterprise/conjur#78](https://github.cyberng.com/Conjur-Enterprise/conjur/pull/78) +- The Kubernetes authenticator status now verifies that the Conjur issuing + certificate and key are valid and match one another. + [Conjur-Enterprise/conjur#79](https://github.cyberng.com/Conjur-Enterprise/conjur/pull/79) +- The Kubernetes authenticator status now verifies that the access token is able + to authenticate with the Kubernetes API. + [Conjur-Enterprise#88](https://github.cyberng.com/Conjur-Enterprise/conjur/pull/88) +- The Kubernetes authenticator status now verifies that the access token has + permission to perform API discovery. + [Conjur-Enterprise#90](https://github.cyberng.com/Conjur-Enterprise/conjur/pull/90) +- API for creating static secret with permissions + +### Fixed +- Improved error messaging when a foreign key constraint violation occurs during + a policy replace. + [Conjur-Enterprise/conjur#93](https://github.cyberng.com/Conjur-Enterprise/conjur/pull/93) +- Conjur API now returns the correct role graph, no longer showing resources that + the user does not have read permissions on. +- conjurctl role retrieve-key will now work when debug logging is turned on. + [CNJR-2954](https://ca-il-jira.il.cyber-ark.com:8443/browse/CNJR-2759) +- conjur-policy-parser now handles explicitly declared YAML document/s, providing + helpful error messages when appropriate. + [CNJR-3059](https://ca-il-jira.il.cyber-ark.com:8443/browse/CNJR-3059) + +## [1.0.21-cloud] - 2024-02-18 +### Changed +- Changing Error logs to Warning +- API for creating static secret with annotations + +## [1.0.20-cloud] - 2024-02-11 +### Added +- API for creating secret + +### Changed +- Changing Info logs to Debug + +## [1.0.19-cloud] - 2024-02-04 +### Added +- API getting the license info (number of used hosts) +- API getting the edge name by id +- Add sort parameter to list issuers + +### Changed +- Update delete issuer return code +- Changing application error to debug logs +- Removing log in case of resource/variable not found + +## [1.0.18-cloud] - 2024-01-21 +### Added +- API checking role of token (used by agent handler) +- Remove secrets scheduled rotation process + +## [1.0.17-cloud] - 2024-01-14 +### Changed +- Version header in v2 apis is not required +- Fix edge slosilo replication +- Add flag 'delete_vars' to delete variables of issuer + +## [1.0.16-cloud] - 2024-01-07 +### Added +- Connect to Database using AWS IAM role +- Add 'short' param to list resources api +- New Add member to group API +- Save first fetch time of pCloud secrets + +## [1.0.15-cloud] - 2023-12-17 +### Added +- Fix edge secrets replication bug +- Tenant Id in the authentication token + +## [1.0.14-cloud] - 2023-11-19 +### Added +- Support custom uuid to edge creation +- Api for checking agent permissions to call edge apis + +### Changed +- Removed unsupported apis + +### Fixed +- Fix permissions visibility bug + +## [1.0.13-cloud] - 2023-11-12 + +## [1.0.12-cloud] - 2023-11-05 +### Changed +- support '/' for workload name in workload wizard +- bad request error for duplicate variable in batch fetch secret +- Add delete edge endpoint [ONYX-46630](https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-46630) + +## [1.0.11-cloud] - 2023-10-29 +### Changed +- Make API-key optional for hosts + +## [1.0.10-cloud] - 2023-10-22 +### Added +- Telemetry logs for ephemeral secrets +- Return conflict for existing issuer when variables associated with it + +## [1.0.9-cloud] - 2023-10-15 +### Added +- Add feature flag endpoint + +## [1.0.8-cloud] - 2023-10-01 +### Changed +- New env variables + +## [1.0.7-cloud] - 2023-09-04 +### Changed +- Renamed `platforms` to `issuers`, changed internal structure of ephemeral secret requests and removed default issuer secret + [ONYX-42993](https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-42993) +- Get all authenticators endpoint, will be used by edge for replication +- Modify edge logs +- Add limit and offset to get all authenticators endpoint [ONYX-44074](https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-44074) + +## [1.0.6-cloud] - 2023-08-21 +### Changed +- Code refactoring + +## [1.0.5-cloud] - 2023-08-16 +### Security +- Previously, attempting to add and remove a privilege in the same policy load + resulted in only the positive privilege (grant, permit) taking effect. Now we + fail safe and the negative privilege statement (revoke, deny) is the final + outcome + [CONJSE-1785](https://ca-il-jira.il.cyber-ark.com:8443/browse/CONJSE-1785) + +## [1.0.4-cloud] - 2023-08-10 +### Security +- Support plural syntax for revoke and deny + [CONJSE-1783](https://ca-il-jira.il.cyber-ark.com:8443/browse/CONJSE-1783) + +### Added +- Added a call to the ephemeral secrets service when an ephemeral secret is requested https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-42995 + +## [1.0.3-cloud] - 2023-07-31 +### Added +- Endpoint for edge installation token generation https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-41981 +- Endpoint for creating edge host https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-41980 +- Added create, update, delete and list REST APIs for Platforms https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-42284 + +## [1.0.2-cloud] - 2023-07-20 +### Changed +- Add max edges endpoint for multi edge https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-41982_ +- Add audit when Edge reports installation completed + +## [1.0.1-cloud] - 2023-07-18 +### Changed +- Improve DB connection usage https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-34591 +- Pull Slosilo library to Conjur +- Change Slosilo id from "authn:account:host/user" to "authn:account:host/user:current" +- Add update slosilo key option to slosilo put key function +- Add slosilo key rotation scheduled task +- Add workload create endpoint https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-41138 +- Modify slosilo endpoint to return current and previous keys +- Add Edges table and allow updating it by Edge https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-40661 +- Mask Edge IP from audits emitted by Edge forwards https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-38139 +- Add an exclude param to resources-list for filtering https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-41894 + +## [1.0.0-cloud] - 2023-06-07 +### Changed +- Improve DB queries for Edge https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-37081 +- Change Slosilo id regex to support: authn:conjur:user/host in addition to authn:conjur +- Split Slosilo key for hosts and users +- Fix No continuation in replication when an error occurs https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-35741 + +## [0.0.11-cloud] - 2023-05-24 +### Changed +- Remove edge-hosts for edge endpoint +- oidc user name to be compare as lowercase https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-37450 +- Support versions field in all secrets endpoint https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-37056 + +## [0.0.10-cloud] - 2023-05-16 +### Added +- Implementation health endpoint + https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-37338 + +## [0.0.9-cloud] - 2023-05-09 +### Added +- Add an option to get all secrets from edge api with encode bse64, by Accept-Encoding header + https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-35742 + +## [0.0.8-cloud] - 2023-04-30 +### Added +- New edge-hosts endpoints for edge +- Api change. Host API key is return as hashed + https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-35892 + +## [0.0.7-cloud] - 2023-03-27 +### Changed +- Merge from master 2023-03-27 to 2023-03-26 + +## [0.0.6-cloud] - 2023-03-19 +### Security +- Updated github-pages version in docs/Gemfile to allow upgrading activesupport + to v7.0.4.2 to resolve CVE-2022-22796 + [cyberark/conjur#2729](https://github.com/cyberark/conjur/pull/2729) +- Upgraded rack to v2.2.6.3 to resolve CVE-2023-27530 + [cyberark/conjur#2739](https://github.com/cyberark/conjur/pull/2739) +- Upgraded rack to v2.2.6.4 to resolve CVE-2023-27539 + [cyberark/conjur#2750](https://github.com/cyberark/conjur/pull/2750) + +## [0.0.5-cloud] - 2023-03-15 +### Changed +- Add get SlosiloKey api + +## [0.0.4-cloud] - 2023-03-12 +### Changed +- Change count=true not to consider limit and sum all + +## [0.0.3-cloud] - 2023-03-06 +### Changed +- Change edge group name + +## [0.0.2-cloud] - 2023-03-06 +### Added +- Edge host endpoint and secret endpoint + +## [0.0.1-cloud] - 2022-01-13 +### Changed +- Remove auto-release options to allow for a pseudo-fork development on a branch + +## [1.20.1] - 2023-10-13 + +### Fixed +- OIDC Authenticator now writes custom certs to a non-default directory instead + of the system default certificate store. + [cyberark/conjur#2988](https://github.com/cyberark/conjur/pull/2988) +- conjurctl role retrieve-key will now work when debug logging is turned on. + [CNJR-2954](https://ca-il-jira.il.cyber-ark.com:8443/browse/CNJR-2759) + +### Added +- Support for the no_proxy & NO_PROXY environment variables for the k8s authenticator. + [CNJR-2759](https://ca-il-jira.il.cyber-ark.com:8443/browse/CNJR-2759) + +### Security +- Upgrade google/cloud-sdk in ci/test_suites/authenticators_k8s/dev/Dockerfile/test + to use latest version (448.0.0) + [cyberark/conjur#2972](https://github.com/cyberark/conjur/pull/2972) + +## [1.20.0] - 2023-09-21 + +### Fixed +- Allow Factories with optional variables to save without error + [cyberark/conjur#2956](https://github.com/cyberark/conjur/pull/2956) +- OIDC authenticators support `https_proxy` and `HTTPS_PROXY` environment variables + [cyberark/conjur#2902](https://github.com/cyberark/conjur/pull/2902) +- Support plural syntax for revoke and deny + [cyberark/conjur#2901](https://github.com/cyberark/conjur/pull/2901) + +### Added +- Support an optional`ca-cert` variable for providing custom certs/chains to verify + OIDC providers or proxies when using the OIDC authenticator + [cyberark/conjur#2933](https://github.com/cyberark/conjur/pull/2933) +- New flag to `conjurctl server` command called `--no-migrate` which allows for skipping + the database migration step when starting the server. + [cyberark/conjur#2895](https://github.com/cyberark/conjur/pull/2895) +- Telemetry support + [cyberark/conjur#2854](https://github.com/cyberark/conjur/pull/2854) +- Introduces support for Policy Factory, which enables resource creation + through a new `factories` API. + [cyberark/conjur#2855](https://github.com/cyberark/conjur/pull/2855/files) +- Use base images with newer Ubuntu and UBI. + Display FIPS Mode status in the UI (requires temporary fix for OpenSSL gem). + [cyberark/conjur#2874](https://github.com/cyberark/conjur/pull/2874) + +### Changed +- The database thread pool max connection size is now based on the number of + web worker threads per process, rather than an arbitrary fixed number. This + mitigates the possibility of a web worker becoming starved while waiting for + a connection to become available. + [cyberark/conjur#2875](https://github.com/cyberark/conjur/pull/2875) +- Changed base-image tagging strategy + [cyberark/conjur#2926](https://github.com/cyberark/conjur/pull/2926) + +### Fixed +- Support Authn-IAM regional requests when host value is missing from signed headers. + [cyberark/conjur#2827](https://github.com/cyberark/conjur/pull/2827) + +### Security +- Support plural syntax for revoke and deny + [cyberark/conjur#2901](https://github.com/cyberark/conjur/pull/2901) +- Previously, attempting to add and remove a privilege in the same policy load + resulted in only the positive privilege (grant, permit) taking effect. Now we + fail safe and the negative privilege statement (revoke, deny) is the final + outcome + [cyberark/conjur#2907](https://github.com/cyberark/conjur/pull/2907) +- Update puma to 6.3.1 to address CVE-2023-40175. + [cyberark/conjur#2925](https://github.com/cyberark/conjur/pull/2925) + +## [1.19.5] - 2023-06-29 ### Security - Update bundler to 2.2.33 to remove CVE-2021-43809 @@ -21,8 +479,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Authn-IAM now uses the host in the signed headers to determine which STS endpoint (global or regional) to use for validation. -## [1.19.4] - 2023-05-12 - ### Changed - OIDC tokens will now have a default ttl of 60 mins [cyberark/conjur#2800](https://github.com/cyberark/conjur/pull/2800) @@ -1045,7 +1501,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added - The first tagged version. -[Unreleased]: https://github.com/cyberark/conjur/compare/v1.19.3...HEAD +[Unreleased]: https://github.com/cyberark/conjur/compare/v1.20.0...HEAD +[1.20.0]: https://github.com/cyberark/conjur/compare/v1.19.5...v1.20.0 +[1.19.5]: https://github.com/cyberark/conjur/compare/v1.19.3...v1.19.5 [1.19.3]: https://github.com/cyberark/conjur/compare/v1.19.2...v1.19.3 [1.19.2]: https://github.com/cyberark/conjur/compare/v1.19.1...v1.19.2 [1.19.1]: https://github.com/cyberark/conjur/compare/v1.19.0...v1.19.1 diff --git a/Dockerfile b/Dockerfile index 96a88bfd99..0c94aa2ee5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,25 +1,35 @@ -FROM cyberark/ubuntu-ruby-fips:latest +FROM cyberark/ubuntu-ruby-builder:latest as builder + +ENV CONJUR_HOME=/opt/conjur-server + +WORKDIR ${CONJUR_HOME} -ENV DEBIAN_FRONTEND=noninteractive \ - PORT=80 \ - LOG_DIR=/opt/conjur-server/log \ - TMP_DIR=/opt/conjur-server/tmp \ - SSL_CERT_DIRECTORY=/opt/conjur/etc/ssl +COPY Gemfile Gemfile.lock ./ +COPY ./gems/ ./gems/ -EXPOSE 80 +RUN bundle config set --local without 'test development' && \ + bundle config set --local deployment true && \ + bundle config set --local path vendor/bundle && \ + bundle config --local jobs "$(nproc --all)" && \ + bundle install && \ + # Remove private keys brought in by gems in their test data + find / -name 'openid_connect-*' -type d -exec find {} -name '*.pem' -type f -delete \; && \ + find / -name 'httpclient-*' -type d -exec find {} -name '*.key' -type f -delete \; && \ + find / -name 'httpclient-*' -type d -exec find {} -name '*.pem' -type f -delete \; + +FROM cyberark/ubuntu-ruby-fips:latest -RUN apt-get update -y && \ - apt-get -y dist-upgrade && \ - apt-get install -y libz-dev +ENV PORT=80 \ + LOG_DIR=${CONJUR_HOME}/log \ + TMP_DIR=${CONJUR_HOME}/tmp \ + SSL_CERT_DIRECTORY=/opt/conjur/etc/ssl \ + RAILS_ENV=production \ + CONJUR_HOME=/opt/conjur-server \ + OPENSSL_CONF=/usr/lib/ssl/openssl_non_fips.cnf -RUN apt-get install -y build-essential \ - curl \ - git \ - ldap-utils \ - tzdata \ - && rm -rf /var/lib/apt/lists/* +ENV PATH="${PATH}:${CONJUR_HOME}/bin" -WORKDIR /opt/conjur-server +WORKDIR ${CONJUR_HOME} # Ensure few required GID0-owned folders to run as a random UID (OpenShift requirement) RUN mkdir -p $TMP_DIR \ @@ -28,20 +38,9 @@ RUN mkdir -p $TMP_DIR \ $SSL_CERT_DIRECTORY/cert \ /run/authn-local -COPY Gemfile \ - Gemfile.lock ./ -COPY gems/ gems/ - - -RUN bundle --without test development - COPY . . +COPY --from=builder ${CONJUR_HOME} ${CONJUR_HOME} -# removing CA bundle of httpclient gem -RUN find / -name httpclient -type d -exec find {} -name *.pem -type f -delete \; - -RUN ln -sf /opt/conjur-server/bin/conjurctl /usr/local/bin/ - -ENV RAILS_ENV production +EXPOSE ${PORT} ENTRYPOINT [ "conjurctl" ] diff --git a/Dockerfile.fpm b/Dockerfile.fpm index ca25aa6995..345c840137 100644 --- a/Dockerfile.fpm +++ b/Dockerfile.fpm @@ -5,8 +5,7 @@ RUN apt-get update -y && \ apt-get install -y zlib1g-dev \ liblzma-dev -ENV BUNDLER_VERSION 2.2.33 -RUN gem install --no-document bundler:$BUNDLER_VERSION fpm +RUN gem install --no-document fpm RUN mkdir -p /src/opt/conjur/project @@ -19,7 +18,7 @@ COPY gems/ gems/ COPY . . # removing CA bundle of httpclient gem -RUN find / -name httpclient -type d -exec find {} -name *.pem -type f -delete \; +RUN find / -name httpclient -type d -exec find {} -name "*.pem" -type f -delete \; ADD debify.sh / diff --git a/Dockerfile.test b/Dockerfile.test index a91f36550c..92b3358c8e 100644 --- a/Dockerfile.test +++ b/Dockerfile.test @@ -1,4 +1,38 @@ ARG VERSION=latest -FROM conjur:${VERSION} -RUN bundle --no-deployment --without '' +FROM cyberark/ubuntu-ruby-builder:latest as builder + +ENV CONJUR_HOME=/opt/conjur-server \ + GEM_HOME=/usr/local/bundle + +WORKDIR ${CONJUR_HOME} + +COPY Gemfile Gemfile.lock ./ +COPY ./gems/ ./gems/ + +RUN bundle config unset --local without && \ + bundle config unset --local path && \ + bundle config set --local deployment false && \ + bundle config --local jobs "$(nproc --all)" && \ + # this is a workaround to allow installation of ruby-debug-ide, for unknown + # reasons the first attempt to install it fails but the subsequent call is + # successful, therefore we try to install again if the first invocation fails + (bundle install || bundle install) && \ + # removing CA bundle of httpclient gem + find / -name 'httpclient-*' -type d -exec find {} -name '*.pem' -type f -delete \; && \ + find / -name 'httpclient-*' -type d -exec find {} -name '*.key' -type f -delete \; && \ + # remove the private key in the oidc_connect gem spec directory + find / -name openid_connect -type d -exec find {} -name '*.pem' -type f -delete \; + +FROM conjur-cloud:${VERSION} + +ENV GEM_HOME=/usr/local/bundle +ENV PATH="${GEM_HOME}/bin:${PATH}" +ENV OPENSSL_CONF=/usr/lib/ssl/openssl_non_fips.cnf + +RUN bundle config unset --local without && \ + bundle config unset --local path && \ + bundle config set --local deployment false && \ + gem install rake + +COPY --from=builder ${GEM_HOME} ${GEM_HOME} diff --git a/Dockerfile.ubi b/Dockerfile.ubi index 3c0e9614ba..644e44f6e9 100644 --- a/Dockerfile.ubi +++ b/Dockerfile.ubi @@ -1,15 +1,28 @@ +# Ruby builder +FROM cyberark/ubi-ruby-builder:latest as builder + +ENV CONJUR_HOME=/opt/conjur-server + +WORKDIR ${CONJUR_HOME} + +COPY Gemfile Gemfile.lock ./ +COPY ./gems/ ./gems/ + +RUN bundle config set --local without 'test development' && \ + bundle config set --local deployment true && \ + bundle config set --local path vendor/bundle && \ + bundle config --local jobs "$(nproc --all)" && \ + bundle install && \ + # removing CA bundle of httpclient gem + find / -name 'httpclient-*' -type d -exec find {} -name '*.pem' -type f -delete \; && \ + find / -name 'httpclient-*' -type d -exec find {} -name '*.key' -type f -delete \; && \ + # remove the private key in the oidc_connect gem spec directory + find / -name 'openid_connect-*' -type d -exec find {} -name '*.pem' -type f -delete \; + # Conjur Base Image (UBI) FROM cyberark/ubi-ruby-fips:latest - -EXPOSE 8080 ARG VERSION -ENV PORT=8080 \ - LOG_DIR=/opt/conjur-server/log \ - TMP_DIR=/opt/conjur-server/tmp \ - SSL_CERT_DIRECTORY=/opt/conjur/etc/ssl \ - RAILS_ENV=production - LABEL name="conjur-ubi" \ vendor="CyberArk" \ version="$VERSION" \ @@ -17,6 +30,15 @@ LABEL name="conjur-ubi" \ summary="Conjur UBI-based image" \ description="Conjur provides secrets management and machine identity for modern infrastructure." +ENV PORT=8080 \ + LOG_DIR=${CONJUR_HOME}/log \ + TMP_DIR=${CONJUR_HOME}/tmp \ + SSL_CERT_DIRECTORY=/opt/conjur/etc/ssl \ + RAILS_ENV=production \ + CONJUR_HOME=/opt/conjur-server + +ENV PATH="${PATH}:${CONJUR_HOME}/bin" + # Create conjur user with one that has known gid / uid. RUN groupadd -r conjur \ -g 777 && \ @@ -28,7 +50,7 @@ RUN groupadd -r conjur \ -s /bin/bash \ -u 777 conjur -WORKDIR /opt/conjur-server +WORKDIR ${CONJUR_HOME} # Ensure few required GID0-owned folders to run as a random UID (OpenShift requirement) RUN mkdir -p "$TMP_DIR" \ @@ -37,50 +59,23 @@ RUN mkdir -p "$TMP_DIR" \ "$SSL_CERT_DIRECTORY/cert" \ /run/authn-local && \ # Use GID of 0 since that is what OpenShift will want to be able to read things - chown conjur:0 "$LOG_DIR" \ + chown -R conjur:0 "$LOG_DIR" \ "$TMP_DIR" \ "$SSL_CERT_DIRECTORY" \ + "$CONJUR_HOME" \ /run/authn-local && \ # We need open group permissions in these directories since OpenShift won't # match our UID when we try to write files to them - chmod 770 "$LOG_DIR" \ + chmod -R 770 "$LOG_DIR" \ "$TMP_DIR" \ "$SSL_CERT_DIRECTORY" \ + "$CONJUR_HOME" \ /run/authn-local -COPY Gemfile \ - Gemfile.lock ./ -COPY gems/ gems/ - -# Install package dependencies for Conjur -RUN INSTALL_PKGS="openldap-clients \ - tzdata" && \ - yum install -y --setopt=tsflags=nodocs $INSTALL_PKGS && \ - rpm -V $INSTALL_PKGS && \ - yum -y clean all --enablerepo='*' - -# Install Gems (and build native gems) for Conjur -RUN INSTALL_PKGS="gcc \ - gcc-c++ \ - git \ - glibc-devel \ - libxml2-devel \ - libxslt-devel \ - make" && \ - yum install -y --setopt=tsflags=nodocs $INSTALL_PKGS && \ - rpm -V $INSTALL_PKGS && \ - # Install the gems dependencies - bundle --without test development && \ - # Remove the build packages - yum remove -y $INSTALL_PKGS && \ - yum -y clean all --enablerepo='*' - -COPY . . - -# removing CA bundle of httpclient gem -RUN find / -name httpclient -type d -exec find {} -name *.pem -type f -delete \; - -RUN ln -sf /opt/conjur-server/bin/conjurctl /usr/local/bin/ +COPY --chown=conjur:0 . . +COPY --from=builder --chown=conjur:0 ${CONJUR_HOME} ${CONJUR_HOME} + +EXPOSE ${PORT} COPY LICENSE.md /licenses/ diff --git a/Gemfile b/Gemfile index 330c68f777..975e9b671a 100644 --- a/Gemfile +++ b/Gemfile @@ -13,22 +13,27 @@ git_source(:github) { |name| "https://github.com/#{name}.git" } # part of a Rails project. The Ruby version is also locked in place by the # Docker base image so it won't be updated with fuzzy matching. +gem 'aws-sdk-rds' +gem 'aws-sdk-appconfigdata' gem 'base58' gem 'command_class' gem 'http', '~> 4.2.0' gem 'iso8601' gem 'jbuilder', '~> 2.7.0' -gem 'nokogiri', '>= 1.8.2' -gem 'puma', '~> 5.6' -gem 'rack', '~> 2.2' -gem 'rails', '~> 6.1', '>= 6.1.4.6' +gem 'nokogiri', '>= 1.16.5' +gem 'puma', '~> 6', '>= 6.4.2' +gem 'rack', '~> 2.2', '>= 2.2.8.1' +gem 'rails', '~> 6.1', '>= 6.1.7.8' gem 'rake' +gem 'rufus-scheduler' gem 'pg' gem 'sequel' gem 'sequel-pg_advisory_locking' gem 'sequel-postgres-schemata', require: false gem 'sequel-rails' +gem 'redis' +gem 'redis-clustering' gem 'activesupport', '~> 6.1', '>= 6.1.4.6' gem 'base32-crockford' @@ -36,11 +41,13 @@ gem 'bcrypt' gem 'gli', require: false gem 'listen' gem 'rexml', '~> 3.2' -gem 'slosilo', '~> 3.0' +gem 'slosilo', path: 'gems/slosilo' # Explicitly required as there are vulnerabilities in older versions gem "ffi", ">= 1.9.24" gem "loofah", ">= 2.2.3" +gem "json-jwt", ">= 1.16.6" +gem "actionpack", ">= 6.1.7.7" # Pinned to update for role member search, using ref so merging and removing # the branch doesn't immediately break this link @@ -55,10 +62,15 @@ gem 'rack-rewrite' gem 'dry-struct' gem 'dry-types' +gem 'dry-validation' gem 'net-ldap' # for AWS rotator gem 'aws-sdk-iam', require: false +gem 'aws-sdk-sns' + +# we need this version since any newer introduces braking change that causes issues with safe_yaml: https://github.com/ruby/psych/discussions/571 +gem 'psych', '=3.3.2' group :production do gem 'rails_12factor' @@ -70,12 +82,15 @@ gem 'kubeclient' gem 'websocket' # authn-oidc, gcp, azure, jwt -gem 'jwt', '2.2.2' # version frozen due to authn-jwt requirements +# gem 'jwt', '2.2.2' # version frozen due to authn-jwt requirements +gem 'jwt', '2.7.1' # authn-oidc -gem 'openid_connect' +gem 'openid_connect', '~> 2.0' gem "anyway_config" gem 'i18n', '~> 1.8.11' +gem 'json_schemer' +gem 'prometheus-client' group :development, :test do gem 'aruba' @@ -86,10 +101,12 @@ group :development, :test do gem 'cucumber', '~> 7.1' gem 'database_cleaner', '~> 1.8' gem 'debase', '~> 0.2.5.beta2' + gem 'debase-ruby_core_source', '3.2.2' gem 'json_spec', '~> 1.1' gem 'faye-websocket' gem 'net-ssh' gem 'parallel' + gem 'parallel_tests' gem 'pry-byebug' gem 'pry-rails' gem 'rails-controller-testing' @@ -99,6 +116,7 @@ group :development, :test do gem 'rspec-core' gem 'rspec-rails' gem 'ruby-debug-ide' + gem 'aws-sdk-sqs' # We use a post-coverage hook to sleep covered processes until we're ready to # collect the coverage reports in CI. Because of this, we don't want bundler diff --git a/Gemfile.lock b/Gemfile.lock index 4eab22f583..a0235c013b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -13,110 +13,129 @@ PATH activesupport (>= 4.2) safe_yaml +PATH + remote: gems/slosilo + specs: + slosilo (3.0.1) + GEM remote: https://rubygems.org/ specs: - actioncable (6.1.7.3) - actionpack (= 6.1.7.3) - activesupport (= 6.1.7.3) + actioncable (6.1.7.8) + actionpack (= 6.1.7.8) + activesupport (= 6.1.7.8) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (6.1.7.3) - actionpack (= 6.1.7.3) - activejob (= 6.1.7.3) - activerecord (= 6.1.7.3) - activestorage (= 6.1.7.3) - activesupport (= 6.1.7.3) + actionmailbox (6.1.7.8) + actionpack (= 6.1.7.8) + activejob (= 6.1.7.8) + activerecord (= 6.1.7.8) + activestorage (= 6.1.7.8) + activesupport (= 6.1.7.8) mail (>= 2.7.1) - actionmailer (6.1.7.3) - actionpack (= 6.1.7.3) - actionview (= 6.1.7.3) - activejob (= 6.1.7.3) - activesupport (= 6.1.7.3) + actionmailer (6.1.7.8) + actionpack (= 6.1.7.8) + actionview (= 6.1.7.8) + activejob (= 6.1.7.8) + activesupport (= 6.1.7.8) mail (~> 2.5, >= 2.5.4) rails-dom-testing (~> 2.0) - actionpack (6.1.7.3) - actionview (= 6.1.7.3) - activesupport (= 6.1.7.3) + actionpack (6.1.7.8) + actionview (= 6.1.7.8) + activesupport (= 6.1.7.8) rack (~> 2.0, >= 2.0.9) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (6.1.7.3) - actionpack (= 6.1.7.3) - activerecord (= 6.1.7.3) - activestorage (= 6.1.7.3) - activesupport (= 6.1.7.3) + actiontext (6.1.7.8) + actionpack (= 6.1.7.8) + activerecord (= 6.1.7.8) + activestorage (= 6.1.7.8) + activesupport (= 6.1.7.8) nokogiri (>= 1.8.5) - actionview (6.1.7.3) - activesupport (= 6.1.7.3) + actionview (6.1.7.8) + activesupport (= 6.1.7.8) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.1, >= 1.2.0) - activejob (6.1.7.3) - activesupport (= 6.1.7.3) + activejob (6.1.7.8) + activesupport (= 6.1.7.8) globalid (>= 0.3.6) - activemodel (6.1.7.3) - activesupport (= 6.1.7.3) - activerecord (6.1.7.3) - activemodel (= 6.1.7.3) - activesupport (= 6.1.7.3) - activestorage (6.1.7.3) - actionpack (= 6.1.7.3) - activejob (= 6.1.7.3) - activerecord (= 6.1.7.3) - activesupport (= 6.1.7.3) + activemodel (6.1.7.8) + activesupport (= 6.1.7.8) + activerecord (6.1.7.8) + activemodel (= 6.1.7.8) + activesupport (= 6.1.7.8) + activestorage (6.1.7.8) + actionpack (= 6.1.7.8) + activejob (= 6.1.7.8) + activerecord (= 6.1.7.8) + activesupport (= 6.1.7.8) marcel (~> 1.0) mini_mime (>= 1.1.0) - activesupport (6.1.7.3) + activesupport (6.1.7.8) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) minitest (>= 5.1) tzinfo (~> 2.0) zeitwerk (~> 2.3) - addressable (2.8.0) - public_suffix (>= 2.0.2, < 5.0) + addressable (2.8.6) + public_suffix (>= 2.0.2, < 6.0) aes_key_wrap (1.1.0) - anyway_config (2.2.3) + anyway_config (2.4.2) ruby-next-core (>= 0.14.0) - aruba (2.0.0) + aruba (2.1.0) bundler (>= 1.17, < 3.0) childprocess (>= 2.0, < 5.0) contracts (>= 0.16.0, < 0.18.0) - cucumber (>= 4.0, < 8.0) + cucumber (>= 4.0, < 9.0) rspec-expectations (~> 3.4) thor (~> 1.0) ast (2.4.2) attr_required (1.0.1) aws-eventstream (1.2.0) - aws-partitions (1.553.0) - aws-sdk-core (3.126.0) + aws-partitions (1.781.0) + aws-sdk-appconfigdata (1.10.0) + aws-sdk-core (~> 3, >= 3.174.0) + aws-sigv4 (~> 1.1) + aws-sdk-core (3.175.0) aws-eventstream (~> 1, >= 1.0.2) - aws-partitions (~> 1, >= 1.525.0) + aws-partitions (~> 1, >= 1.651.0) + aws-sigv4 (~> 1.5) + jmespath (~> 1, >= 1.6.1) + aws-sdk-iam (1.81.0) + aws-sdk-core (~> 3, >= 3.174.0) aws-sigv4 (~> 1.1) - jmespath (~> 1.0) - aws-sdk-iam (1.66.0) - aws-sdk-core (~> 3, >= 3.126.0) + aws-sdk-rds (1.182.0) + aws-sdk-core (~> 3, >= 3.174.0) aws-sigv4 (~> 1.1) - aws-sigv4 (1.4.0) + aws-sdk-sns (1.62.0) + aws-sdk-core (~> 3, >= 3.174.0) + aws-sigv4 (~> 1.1) + aws-sdk-sqs (1.58.0) + aws-sdk-core (~> 3, >= 3.174.0) + aws-sigv4 (~> 1.1) + aws-sigv4 (1.5.2) aws-eventstream (~> 1, >= 1.0.2) base32-crockford (0.1.0) base58 (0.2.3) - bcrypt (3.1.16) - bindata (2.4.10) + base64 (0.2.0) + bcrypt (3.1.19) + bindata (2.5.0) builder (3.2.4) byebug (11.1.3) childprocess (4.1.0) - ci_reporter (2.0.0) + ci_reporter (2.1.0) builder (>= 2.1.2) + rexml ci_reporter_rspec (1.0.0) ci_reporter (~> 2.0) rspec (>= 2.14, < 4) coderay (1.1.3) command_class (0.0.2) - concurrent-ruby (1.2.2) - conjur-api (5.3.8.pre.194) + concurrent-ruby (1.2.3) + conjur-api (5.4.2.pre.638) activesupport (>= 4.2) addressable (~> 2.0) rest-client @@ -129,13 +148,14 @@ GEM netrc (~> 0.10) table_print (~> 1.5) xdg (= 2.2.3) - conjur-debify (0.0.1.pre.47) + conjur-debify (3.0.0) conjur-api (~> 5.3) conjur-cli (~> 6) docker-api (~> 2.0) gli conjur-rack-heartbeat (2.2.0) rack + connection_pool (2.4.1) contracts (0.17) crack (0.4.5) rexml @@ -154,10 +174,10 @@ GEM mime-types (~> 3.3, >= 3.3.1) multi_test (~> 0.1, >= 0.1.2) sys-uname (~> 1.2, >= 1.2.2) - cucumber-core (10.1.0) + cucumber-core (10.1.1) cucumber-gherkin (~> 22.0, >= 22.0.0) cucumber-messages (~> 17.1, >= 17.1.1) - cucumber-tag-expressions (~> 4.0, >= 4.0.2) + cucumber-tag-expressions (~> 4.1, >= 4.1.0) cucumber-create-meta (6.0.4) cucumber-messages (~> 17.1, >= 17.1.1) sys-uname (~> 1.2, >= 1.2.2) @@ -168,247 +188,304 @@ GEM cucumber-messages (~> 17.1, >= 17.1.0) cucumber-messages (17.1.1) cucumber-tag-expressions (4.1.0) - cucumber-wire (6.2.0) + cucumber-wire (6.2.1) cucumber-core (~> 10.1, >= 10.1.0) cucumber-cucumber-expressions (~> 14.0, >= 14.0.0) - cucumber-messages (~> 17.1, >= 17.1.1) - database_cleaner (1.8.5) + database_cleaner (1.99.0) date (3.3.3) debase (0.2.5.beta2) debase-ruby_core_source (>= 0.10.12) - debase-ruby_core_source (0.10.13) + debase-ruby_core_source (3.2.2) deep_merge (1.2.2) - diff-lcs (1.4.4) + diff-lcs (1.5.0) docile (1.4.0) docker-api (2.2.0) excon (>= 0.47.0) multi_json - domain_name (0.5.20190701) - unf (>= 0.0.5, < 1.0.0) - dry-configurable (0.13.0) + domain_name (0.6.20240107) + dry-configurable (1.1.0) + dry-core (~> 1.0, < 2) + zeitwerk (~> 2.6) + dry-core (1.0.0) concurrent-ruby (~> 1.0) - dry-core (~> 0.6) - dry-container (0.9.0) + zeitwerk (~> 2.6) + dry-inflector (1.0.0) + dry-initializer (3.1.1) + dry-logic (1.5.0) concurrent-ruby (~> 1.0) - dry-configurable (~> 0.13, >= 0.13.0) - dry-core (0.7.1) + dry-core (~> 1.0, < 2) + zeitwerk (~> 2.6) + dry-schema (1.13.3) concurrent-ruby (~> 1.0) - dry-inflector (0.2.1) - dry-logic (1.2.0) - concurrent-ruby (~> 1.0) - dry-core (~> 0.5, >= 0.5) - dry-struct (1.4.0) - dry-core (~> 0.5, >= 0.5) - dry-types (~> 1.5) + dry-configurable (~> 1.0, >= 1.0.1) + dry-core (~> 1.0, < 2) + dry-initializer (~> 3.0) + dry-logic (>= 1.4, < 2) + dry-types (>= 1.7, < 2) + zeitwerk (~> 2.6) + dry-struct (1.6.0) + dry-core (~> 1.0, < 2) + dry-types (>= 1.7, < 2) ice_nine (~> 0.11) - dry-types (1.5.1) + zeitwerk (~> 2.6) + dry-types (1.7.1) + concurrent-ruby (~> 1.0) + dry-core (~> 1.0) + dry-inflector (~> 1.0) + dry-logic (~> 1.4) + zeitwerk (~> 2.6) + dry-validation (1.10.0) concurrent-ruby (~> 1.0) - dry-container (~> 0.3) - dry-core (~> 0.5, >= 0.5) - dry-inflector (~> 0.1, >= 0.1.2) - dry-logic (~> 1.0, >= 1.0.2) + dry-core (~> 1.0, < 2) + dry-initializer (~> 3.0) + dry-schema (>= 1.12, < 2) + zeitwerk (~> 2.6) erubi (1.12.0) + et-orbi (1.2.11) + tzinfo event_emitter (0.2.6) eventmachine (1.2.7) - excon (0.91.0) - faye-websocket (0.11.1) + excon (0.100.0) + faraday (2.7.10) + faraday-net_http (>= 2.0, < 3.1) + ruby2_keywords (>= 0.0.4) + faraday-follow_redirects (0.3.0) + faraday (>= 1, < 3) + faraday-net_http (3.0.2) + faye-websocket (0.11.2) eventmachine (>= 0.12.0) websocket-driver (>= 0.5.1) - ffi (1.15.4) + ffi (1.15.5) ffi-compiler (1.0.1) ffi (>= 1.0.0) rake + fugit (1.11.1) + et-orbi (~> 1, >= 1.2.11) + raabro (~> 1.4) gli (2.21.0) - globalid (1.1.0) - activesupport (>= 5.0) + globalid (1.2.1) + activesupport (>= 6.1) haikunator (1.1.1) + hana (1.3.7) hashdiff (1.0.1) - highline (2.0.3) + highline (2.1.0) http (4.2.0) addressable (~> 2.3) http-cookie (~> 1.0) http-form_data (~> 2.0) http-parser (~> 1.2.0) http-accept (1.7.0) - http-cookie (1.0.4) + http-cookie (1.0.5) domain_name (~> 0.5) http-form_data (2.3.0) http-parser (1.2.3) ffi-compiler (>= 1.0, < 2.0) - httpclient (2.8.3) i18n (1.8.11) concurrent-ruby (~> 1.0) ice_nine (0.11.2) iso8601 (0.13.0) - jaro_winkler (1.5.4) + jaro_winkler (1.5.6) jbuilder (2.7.0) activesupport (>= 4.2.0) multi_json (>= 1.2) - jmespath (1.6.1) - json-jwt (1.13.0) + jmespath (1.6.2) + json-jwt (1.16.6) activesupport (>= 4.2) aes_key_wrap + base64 bindata + faraday (~> 2.0) + faraday-follow_redirects + json_schemer (1.0.3) + hana (~> 1.3) + regexp_parser (~> 2.0) + simpleidn (~> 0.2) json_spec (1.1.5) multi_json (~> 1.0) rspec (>= 2.0, < 4.0) - jsonpath (1.1.0) + jsonpath (1.1.3) multi_json - jwt (2.2.2) - kubeclient (4.9.3) - http (>= 3.0, < 5.0) + jwt (2.7.1) + kubeclient (4.11.0) + http (>= 3.0, < 6.0) jsonpath (~> 1.0) recursive-open-struct (~> 1.1, >= 1.1.1) rest-client (~> 2.0) kwalify (0.7.2) - listen (3.7.0) + listen (3.8.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) - loofah (2.20.0) + loofah (2.21.3) crass (~> 1.0.2) - nokogiri (>= 1.5.9) + nokogiri (>= 1.12.0) mail (2.8.1) mini_mime (>= 0.1.1) net-imap net-pop net-smtp - marcel (1.0.2) + marcel (1.0.4) method_source (1.0.0) - mime-types (3.4.1) + mime-types (3.5.2) mime-types-data (~> 3.2015) - mime-types-data (3.2022.0105) + mime-types-data (3.2024.0305) mini_mime (1.1.2) - minitest (5.18.0) + minitest (5.22.2) multi_json (1.15.0) multi_test (0.1.2) - net-imap (0.3.4) + net-imap (0.3.7) date net-protocol - net-ldap (0.17.0) + net-ldap (0.18.0) net-pop (0.1.2) net-protocol net-protocol (0.2.1) timeout net-smtp (0.3.3) net-protocol - net-ssh (6.1.0) + net-ssh (7.1.0) netrc (0.11.0) - nio4r (2.5.9) - nokogiri (1.14.3-x86_64-darwin) + nio4r (2.7.3) + nokogiri (1.16.7-aarch64-linux) + racc (~> 1.4) + nokogiri (1.16.7-arm64-darwin) racc (~> 1.4) - nokogiri (1.14.3-x86_64-linux) + nokogiri (1.16.7-x86_64-darwin) racc (~> 1.4) - openid_connect (1.3.0) + nokogiri (1.16.7-x86_64-linux) + racc (~> 1.4) + openid_connect (2.2.0) activemodel attr_required (>= 1.0.0) - json-jwt (>= 1.5.0) - rack-oauth2 (>= 1.6.1) - swd (>= 1.0.0) + faraday (~> 2.0) + faraday-follow_redirects + json-jwt (>= 1.16) + net-smtp + rack-oauth2 (~> 2.2) + swd (~> 2.0) tzinfo validate_email validate_url - webfinger (>= 1.0.1) - parallel (1.21.0) - parser (3.0.3.2) + webfinger (~> 2.0) + parallel (1.23.0) + parallel_tests (4.2.1) + parallel + parser (3.2.2.3) ast (~> 2.4.1) - pg (1.2.3) + racc + pg (1.5.3) powerpack (0.1.3) - pry (0.13.1) + prometheus-client (3.0.0) + pry (0.14.2) coderay (~> 1.1) method_source (~> 1.0) - pry-byebug (3.9.0) + pry-byebug (3.10.1) byebug (~> 11.0) - pry (~> 0.13.0) + pry (>= 0.13, < 0.15) pry-rails (0.3.9) pry (>= 0.10.4) - public_suffix (4.0.6) - puma (5.6.4) + psych (3.3.2) + public_suffix (5.0.4) + puma (6.4.2) nio4r (~> 2.0) - racc (1.6.2) - rack (2.2.6.4) - rack-oauth2 (1.19.0) + raabro (1.4.0) + racc (1.7.1) + rack (2.2.9) + rack-oauth2 (2.2.0) activesupport attr_required - httpclient + faraday (~> 2.0) + faraday-follow_redirects json-jwt (>= 1.11.0) rack (>= 2.1.0) rack-rewrite (1.5.1) rack-test (2.1.0) rack (>= 1.3) - rails (6.1.7.3) - actioncable (= 6.1.7.3) - actionmailbox (= 6.1.7.3) - actionmailer (= 6.1.7.3) - actionpack (= 6.1.7.3) - actiontext (= 6.1.7.3) - actionview (= 6.1.7.3) - activejob (= 6.1.7.3) - activemodel (= 6.1.7.3) - activerecord (= 6.1.7.3) - activestorage (= 6.1.7.3) - activesupport (= 6.1.7.3) + rails (6.1.7.8) + actioncable (= 6.1.7.8) + actionmailbox (= 6.1.7.8) + actionmailer (= 6.1.7.8) + actionpack (= 6.1.7.8) + actiontext (= 6.1.7.8) + actionview (= 6.1.7.8) + activejob (= 6.1.7.8) + activemodel (= 6.1.7.8) + activerecord (= 6.1.7.8) + activestorage (= 6.1.7.8) + activesupport (= 6.1.7.8) bundler (>= 1.15.0) - railties (= 6.1.7.3) + railties (= 6.1.7.8) sprockets-rails (>= 2.0.0) rails-controller-testing (1.0.5) actionpack (>= 5.0.1.rc1) actionview (>= 5.0.1.rc1) activesupport (>= 5.0.1.rc1) - rails-dom-testing (2.0.3) - activesupport (>= 4.2.0) + rails-dom-testing (2.1.1) + activesupport (>= 5.0.0) + minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.5.0) - loofah (~> 2.19, >= 2.19.1) + rails-html-sanitizer (1.6.0) + loofah (~> 2.21) + nokogiri (~> 1.14) rails_12factor (0.0.3) rails_serve_static_assets rails_stdout_logging rails_layout (1.0.42) rails_serve_static_assets (0.0.5) rails_stdout_logging (0.0.5) - railties (6.1.7.3) - actionpack (= 6.1.7.3) - activesupport (= 6.1.7.3) + railties (6.1.7.8) + actionpack (= 6.1.7.8) + activesupport (= 6.1.7.8) method_source rake (>= 12.2) thor (~> 1.0) - rainbow (3.0.0) + rainbow (3.1.1) rake (13.0.6) rake_shared_context (0.3.0) - rb-fsevent (0.11.0) + rb-fsevent (0.11.2) rb-inotify (0.10.1) ffi (~> 1.0) recursive-open-struct (1.1.3) - reek (6.0.6) + redis (5.2.0) + redis-client (>= 0.22.0) + redis-client (0.22.2) + connection_pool + redis-cluster-client (0.11.0) + redis-client (~> 0.22) + redis-clustering (5.2.0) + redis (= 5.2.0) + redis-cluster-client (>= 0.7.11) + reek (6.1.4) kwalify (~> 0.7.0) - parser (~> 3.0.0) + parser (~> 3.2.0) rainbow (>= 2.0, < 4.0) + regexp_parser (2.8.1) rest-client (2.1.0) http-accept (>= 1.7.0, < 2.0) http-cookie (>= 1.0.2, < 2.0) mime-types (>= 1.16, < 4.0) netrc (~> 0.8) rexml (3.2.5) - rspec (3.10.0) - rspec-core (~> 3.10.0) - rspec-expectations (~> 3.10.0) - rspec-mocks (~> 3.10.0) - rspec-core (3.10.1) - rspec-support (~> 3.10.0) - rspec-expectations (3.10.1) + rspec (3.12.0) + rspec-core (~> 3.12.0) + rspec-expectations (~> 3.12.0) + rspec-mocks (~> 3.12.0) + rspec-core (3.12.2) + rspec-support (~> 3.12.0) + rspec-expectations (3.12.3) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.10.0) - rspec-mocks (3.10.2) + rspec-support (~> 3.12.0) + rspec-mocks (3.12.5) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.10.0) - rspec-rails (5.0.2) - actionpack (>= 5.2) - activesupport (>= 5.2) - railties (>= 5.2) - rspec-core (~> 3.10) - rspec-expectations (~> 3.10) - rspec-mocks (~> 3.10) - rspec-support (~> 3.10) - rspec-support (3.10.3) + rspec-support (~> 3.12.0) + rspec-rails (6.0.3) + actionpack (>= 6.1) + activesupport (>= 6.1) + railties (>= 6.1) + rspec-core (~> 3.12) + rspec-expectations (~> 3.12) + rspec-mocks (~> 3.12) + rspec-support (~> 3.12) + rspec-support (3.12.0) rubocop (0.58.2) jaro_winkler (~> 1.5.1) parallel (~> 1.10) @@ -421,85 +498,99 @@ GEM rubocop (>= 0.35.1) ruby-debug-ide (0.7.3) rake (>= 0.8.1) - ruby-next-core (0.14.0) - ruby-progressbar (1.11.0) + ruby-next-core (0.15.3) + ruby-progressbar (1.13.0) + ruby2_keywords (0.0.5) + rufus-scheduler (3.9.1) + fugit (~> 1.1, >= 1.1.6) safe_yaml (1.0.5) - sequel (5.51.0) + sequel (5.69.0) sequel-pg_advisory_locking (1.0.1) sequel sequel-postgres-schemata (0.1.3) sequel (>= 4.3, < 6) - sequel-rails (1.1.1) + sequel-rails (1.2.0) actionpack (>= 4.0.0) activemodel (>= 4.0.0) railties (>= 4.0.0) sequel (>= 3.28, < 6.0) - simplecov (0.21.2) + simplecov (0.22.0) docile (~> 1.1) simplecov-html (~> 0.11) simplecov_json_formatter (~> 0.1) simplecov-html (0.12.3) simplecov_json_formatter (0.1.4) - slosilo (3.0.1) + simpleidn (0.2.1) + unf (~> 0.1.4) spring (2.1.0) spring-commands-cucumber (1.0.1) spring (>= 0.9.1) spring-commands-rspec (1.0.4) spring (>= 0.9.1) - sprockets (4.2.0) + sprockets (4.2.1) concurrent-ruby (~> 1.0) rack (>= 2.2.4, < 4) - sprockets-rails (3.4.2) - actionpack (>= 5.2) - activesupport (>= 5.2) + sprockets-rails (3.5.2) + actionpack (>= 6.1) + activesupport (>= 6.1) sprockets (>= 3.0.0) - swd (1.3.0) + swd (2.0.2) activesupport (>= 3) attr_required (>= 0.0.5) - httpclient (>= 2.4) - sys-uname (1.2.2) + faraday (~> 2.0) + faraday-follow_redirects + sys-uname (1.2.3) ffi (~> 1.1) table_print (1.5.7) - thor (1.2.1) - timeout (0.3.2) + thor (1.2.2) + timeout (0.4.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) unf (0.1.4) unf_ext - unf_ext (0.0.8.1) + unf_ext (0.0.9) unicode-display_width (1.8.0) validate_email (0.1.6) activemodel (>= 3.0) mail (>= 2.2.5) - validate_url (1.0.13) + validate_url (1.0.15) activemodel (>= 3.0.0) public_suffix vcr (6.1.0) - webfinger (1.2.0) + webfinger (2.1.2) activesupport - httpclient (>= 2.4) - webmock (3.14.0) + faraday (~> 2.0) + faraday-follow_redirects + webmock (3.18.1) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - webrick (1.7.0) + webrick (1.8.1) websocket (1.2.9) - websocket-driver (0.7.5) + websocket-driver (0.7.6) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) xdg (2.2.3) - zeitwerk (2.6.7) + zeitwerk (2.6.13) PLATFORMS + aarch64-linux + arm64-darwin-22 x86_64-darwin-20 x86_64-darwin-21 + x86_64-darwin-22 x86_64-linux DEPENDENCIES + actionpack (>= 6.1.7.7) activesupport (~> 6.1, >= 6.1.4.6) anyway_config aruba + aws-sdk-appconfigdata aws-sdk-iam + aws-sdk-rds + aws-sdk-sns + aws-sdk-sqs base32-crockford base58 bcrypt @@ -515,8 +606,10 @@ DEPENDENCIES cucumber (~> 7.1) database_cleaner (~> 1.8) debase (~> 0.2.5.beta2) + debase-ruby_core_source (= 3.2.2) dry-struct dry-types + dry-validation event_emitter faye-websocket ffi (>= 1.9.24) @@ -526,28 +619,35 @@ DEPENDENCIES i18n (~> 1.8.11) iso8601 jbuilder (~> 2.7.0) + json-jwt (>= 1.16.6) + json_schemer json_spec (~> 1.1) - jwt (= 2.2.2) + jwt (= 2.7.1) kubeclient listen loofah (>= 2.2.3) net-ldap net-ssh - nokogiri (>= 1.8.2) - openid_connect + nokogiri (>= 1.16.5) + openid_connect (~> 2.0) parallel + parallel_tests pg + prometheus-client pry-byebug pry-rails - puma (~> 5.6) - rack (~> 2.2) + psych (= 3.3.2) + puma (~> 6, >= 6.4.2) + rack (~> 2.2, >= 2.2.8.1) rack-rewrite - rails (~> 6.1, >= 6.1.4.6) + rails (~> 6.1, >= 6.1.7.8) rails-controller-testing rails_12factor rails_layout rake rake_shared_context + redis + redis-clustering reek rexml (~> 3.2) rspec @@ -556,12 +656,13 @@ DEPENDENCIES rubocop (~> 0.58.0) rubocop-checkstyle_formatter ruby-debug-ide + rufus-scheduler sequel sequel-pg_advisory_locking sequel-postgres-schemata sequel-rails simplecov - slosilo (~> 3.0) + slosilo! spring spring-commands-cucumber spring-commands-rspec @@ -572,4 +673,4 @@ DEPENDENCIES websocket BUNDLED WITH - 2.2.33 + 2.4.14 diff --git a/Jenkinsfile b/Jenkinsfile index cef3cd441e..5d100f0c1b 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -45,40 +45,20 @@ These are defined in runConjurTests, and also include the one-offs azure_authenticator gcp_authenticator */ - -// Automated release, promotion and dependencies -properties([ - // Include the automated release parameters for the build - release.addParams(), - // Dependencies of the project that should trigger builds - dependencies(['cyberark/conjur-base-image', - 'cyberark/conjur-api-ruby', - 'conjurinc/debify']) -]) - -// Performs release promotion. No other stages will be run -if (params.MODE == "PROMOTE") { - release.promote(params.VERSION_TO_PROMOTE) { sourceVersion, targetVersion, assetDirectory -> - sh "docker pull registry.tld/cyberark/conjur:${sourceVersion}" - sh "docker tag registry.tld/cyberark/conjur:${sourceVersion} conjur:${sourceVersion}" - sh "docker pull registry.tld/conjur-ubi:${sourceVersion}" - sh "docker tag registry.tld/conjur-ubi:${sourceVersion} conjur-ubi:${sourceVersion}" - sh "summon -f ./secrets.yml ./publish-images.sh --promote --redhat --base-version=${sourceVersion} --version=${targetVersion}" - - // Trigger Conjurops build to push newly promoted releases of conjur to ConjurOps Staging - build( - job:'../conjurinc--conjurops/master', - parameters:[ - string(name: 'conjur_oss_source_image', value: "cyberark/conjur:${targetVersion}") - ], - wait: false - ) - } - return -} - +@Library("product-pipelines-shared-library") _ +@Library("conjur-shared-library") _2 + +isStartedByTimer = currentBuild.getBuildCauses()[0]["shortDescription"].matches("Started by timer") +def devEcr = conjurCloudUtils.generateRepositoryDetails("dev", "conjur", "dev").get("repoName") +def mainEcr = conjurCloudUtils.generateRepositoryDetails("dev", "conjur", "main").get("repoName") +def isMain = (env.BRANCH_NAME == 'conjur-cloud') +def conjurVersion + +// Break the total number of tests into a subset of tests. +// This will give 3 nested lists of tests to run, which is +// distributed over 3 jenkins agents. pipeline { - agent { label 'executor-v2' } + agent { label 'conjur-enterprise-common-agent' } options { timestamps() @@ -86,18 +66,13 @@ pipeline { timeout(time: 2, unit: 'HOURS') } - // "parameterizedCron" is defined by this native Jenkins plugin: - // https://plugins.jenkins.io/parameterized-scheduler/ - // "getDailyCronString" is defined by us (URL is wrapped): - // https://github.com/conjurinc/jenkins-pipeline-library/blob/master/vars/ - // getDailyCronString.groovy triggers { - parameterizedCron(getDailyCronString("%NIGHTLY=true")) + cron(env.BRANCH_NAME == "conjur-cloud" ? "H H(01-02) * * *" : "") } parameters { booleanParam( - name: 'NIGHTLY', + name: 'FIPS', defaultValue: false, description: 'Run tests on all agents and environment including: FIPS' ) @@ -119,50 +94,42 @@ pipeline { } environment { - // Sets the MODE to the specified or autocalculated value as appropriate - MODE = release.canonicalizeMode() + TAG_SHA = tagWithSHA() } stages { - // Aborts any builds triggered by another project that wouldn't include any changes - stage ("Skip build if triggering job didn't create a release") { - when { - expression { - MODE == "SKIP" - } - } + // Pre-allocate agents to fail fast if there's an issue with the pool + // and to pre-configure the git environment before changes occur. + stage('Get InfraPool Agents') { steps { script { - currentBuild.result = 'ABORTED' - error("Aborting build because this build was triggered from upstream, but no release was built") + INFRAPOOL_EXECUTORV2_AGENTS = getInfraPoolAgent.connected(type: "ExecutorV2", quantity: 3, duration: 1) + INFRAPOOL_EXECUTORV2_AGENT_0 = INFRAPOOL_EXECUTORV2_AGENTS[0] + INFRAPOOL_EXECUTORV2_AGENT_1 = INFRAPOOL_EXECUTORV2_AGENTS[1] + INFRAPOOL_EXECUTORV2_AGENT_2 = INFRAPOOL_EXECUTORV2_AGENTS[2] + + INFRAPOOL_EXECUTORV2_RHELEE_AGENTS = getInfraPoolAgent.connected(type: "ExecutorV2RHELEE", quantity: 3, duration: 1) + INFRAPOOL_EXECUTORV2_RHELEE_AGENT_0 = INFRAPOOL_EXECUTORV2_RHELEE_AGENTS[0] + INFRAPOOL_EXECUTORV2_RHELEE_AGENT_1 = INFRAPOOL_EXECUTORV2_RHELEE_AGENTS[1] + INFRAPOOL_EXECUTORV2_RHELEE_AGENT_2 = INFRAPOOL_EXECUTORV2_RHELEE_AGENTS[2] + + INFRAPOOL_AZURE_EXECUTORV2_AGENT_0 = getInfraPoolAgent.connected(type: "AzureExecutorV2", quantity: 1, duration: 1)[0] + + INFRAPOOL_GCP_EXECUTORV2_AGENT_0 = getInfraPoolAgent.connected(type: "GcpExecutorV2", quantity: 1, duration: 1)[0] + + // Break the total number of tests into a subset of tests. + // This will give 3 nested lists of tests to run, which is + // distributed over 3 jenkins agents. + NESTED_ARRAY_OF_TESTS_TO_RUN = collateTests(INFRAPOOL_EXECUTORV2_AGENT_0) } } } // Generates a VERSION file based on the current build number and latest version in CHANGELOG.md stage('Validate Changelog and set version') { steps { - updateVersion("CHANGELOG.md", "${BUILD_NUMBER}") - stash name: 'version_info', includes: 'VERSION' - } - } - - stage('Fetch tags') { - steps { - withCredentials( - [ - usernameColonPassword( - credentialsId: 'conjur-jenkins-api', variable: 'GITCREDS' - ) - ] - ) { - sh ''' - git fetch --tags "$( - git remote get-url origin | - sed -e "s|https://|https://$GITCREDS@|" - )" - # print them out to make sure, can remove when this is robust - git tag - ''' + script { + updateVersion(INFRAPOOL_EXECUTORV2_AGENT_0, "CHANGELOG.md", "${BUILD_NUMBER}") + INFRAPOOL_EXECUTORV2_AGENT_0.agentStash name: 'version_info', includes: 'VERSION' } } } @@ -172,7 +139,17 @@ pipeline { expression { params.RUN_ONLY == '' } } steps { - sh 'ci/parse-changelog' + script { + INFRAPOOL_EXECUTORV2_AGENT_0.agentSh 'ci/parse-changelog' + } + } + } + + stage('Mark Workspace as Safe Git Directory'){ + steps { + script { + sh "git config --global --add safe.directory $WORKSPACE" + } } } @@ -201,23 +178,27 @@ pipeline { // nightly builds) branch "master" - // Always run the full pipeline on tags of the form v* - tag "v*" + // Always run the full pipeline on tags + buildingTag() } } stages { stage('Build Docker Image') { steps { - sh './build.sh --jenkins' + script { + INFRAPOOL_EXECUTORV2_AGENT_0.agentSh './build.sh --jenkins' + } } } stage('Push images to internal registry') { steps { - // Push images to the internal registry so that they can be used - // by tests, even if the tests run on a different executor. - sh './publish-images.sh --internal' + script { + // Push images to the internal registry so that they can be used + // by tests, even if the tests run on a different executor. + INFRAPOOL_EXECUTORV2_AGENT_0.agentSh './publish-images.sh --internal' + } } } @@ -228,24 +209,24 @@ pipeline { parallel { stage("Scan Docker Image for fixable issues") { steps { - scanAndReport("conjur:${tagWithSHA()}", "HIGH", false) + scanAndReport(INFRAPOOL_EXECUTORV2_AGENT_0, "conjur-cloud:${TAG_SHA}", "HIGH", false) } } stage("Scan Docker image for total issues") { steps { - scanAndReport("conjur:${tagWithSHA()}", "NONE", true) - } - } - stage("Scan UBI-based Docker Image for fixable issues") { - steps { - scanAndReport("conjur-ubi:${tagWithSHA()}", "HIGH", false) - } - } - stage("Scan UBI-based Docker image for total issues") { - steps { - scanAndReport("conjur-ubi:${tagWithSHA()}", "NONE", true) + scanAndReport(INFRAPOOL_EXECUTORV2_AGENT_0, "conjur-cloud:${TAG_SHA}", "NONE", true) } } + //stage("Scan UBI-based Docker Image for fixable issues") { + //steps { + //scanAndReport(INFRAPOOL_EXECUTORV2_AGENT_0, "conjur-ubi-cloud:${TAG_SHA}", "HIGH", false) + //} + //} + //stage("Scan UBI-based Docker image for total issues") { + //steps { + //scanAndReport(INFRAPOOL_EXECUTORV2_AGENT_0, "conjur-ubi-cloud:${TAG_SHA}", "NONE", true) + //} + //} } } @@ -257,7 +238,7 @@ pipeline { steps { catchError(buildResult: 'SUCCESS', stageResult: 'FAILURE') { script { - ccCoverage.dockerPrep() + INFRAPOOL_EXECUTORV2_AGENT_0.agentSh 'mkdir -p coverage' sh 'mkdir -p coverage' env.CODE_CLIMATE_PREPARED = "true" } @@ -268,107 +249,386 @@ pipeline { // Run outside parallel block to avoid external pressure stage('RSpec - Standard agent tests') { steps { - sh 'ci/test rspec' + script { + INFRAPOOL_EXECUTORV2_AGENT_0.agentSh 'ci/test rspec' + INFRAPOOL_EXECUTORV2_AGENT_0.agentStash( + name: 'rspecTestResult', + includes: ''' + container_logs/*/* + ''' + ) + } + } + post { + always { + script { + INFRAPOOL_EXECUTORV2_AGENT_0.agentStash( + name: 'rspecTestResult', + includes: ''' + container_logs/*/* + ''' + ) + } + script { + dir('rspec-test') { + INFRAPOOL_EXECUTORV2_AGENT_0.agentUnstash 'rspecTestResult' + } + } + archiveArtifacts( + artifacts: "rspec-test/container_logs/*/*", + fingerprint: false, + allowEmptyArchive: true + ) + } } } // Run outside parallel block to reduce main Jenkins executor load. - stage('Nightly Only') { + // Should no be run by default in conjur cloud + stage('FIPS Only') { when { - expression { params.NIGHTLY } + expression { params.FIPS } } environment { - CUCUMBER_FILTER_TAGS = "${params.CUCUMBER_FILTER_TAGS}" + INFRAPOOL_CUCUMBER_FILTER_TAGS = "${params.CUCUMBER_FILTER_TAGS}" } stages { - stage('EE FIPS agent tests') { - agent { label 'executor-v2-rhel-ee' } + stage("RSpec - EE FIPS agent tests") { steps { - sh(script: 'cat /etc/os-release', label: 'RHEL version') - sh(script: 'docker --version', label: 'Docker version') - unstash 'version_info' - // Catch errors so remaining steps always run. - catchError { - // Run outside parallel block to avoid external pressure - script { - stage("RSpec - EE FIPS agent tests") { - sh "ci/test rspec" + script { + INFRAPOOL_EXECUTORV2_RHELEE_AGENT_0.agentSh(script: 'cat /etc/os-release', label: 'RHEL version') + INFRAPOOL_EXECUTORV2_RHELEE_AGENT_0.agentSh(script: 'docker --version', label: 'Docker version') + addNewImagesToAgent(INFRAPOOL_EXECUTORV2_RHELEE_AGENT_0) + INFRAPOOL_EXECUTORV2_RHELEE_AGENT_0.agentUnstash name: 'version_info' + // Catch errors so remaining steps always run. + catchError { + // Run outside parallel block to avoid external pressure + INFRAPOOL_EXECUTORV2_RHELEE_AGENT_0.agentSh "ci/test rspec" + } + } + } + } + + stage('EE FIPS parallel') { + parallel { + stage('EE FIPS agent tests') { + when { + expression { + testShouldRunOnAgent( + params.RUN_ONLY, + runSpecificTestOnAgent(params.RUN_ONLY, NESTED_ARRAY_OF_TESTS_TO_RUN[0]) + ) } } - runConjurTests(params.RUN_ONLY) + steps { + script { + addNewImagesToAgent(INFRAPOOL_EXECUTORV2_RHELEE_AGENT_0) + INFRAPOOL_EXECUTORV2_RHELEE_AGENT_0.agentUnstash name: 'version_info' + runConjurTests( + INFRAPOOL_EXECUTORV2_RHELEE_AGENT_0, + params.RUN_ONLY, + NESTED_ARRAY_OF_TESTS_TO_RUN[0] + ) + } + } + post { + always { + script { + INFRAPOOL_EXECUTORV2_RHELEE_AGENT_0.agentStash( + name: 'testResultEE', + includes: ''' + cucumber/*/*.*, + container_logs/*/*, + spec/reports/*.xml, + spec/reports-audit/*.xml, + gems/conjur-rack/spec/reports/*.xml, + cucumber/*/features/reports/**/*.xml + ''' + ) + } + } + } } + // Run a subset of tests on a second agent to prevent oversubscribing the hardware + stage('EE FIPS agent2 tests') { + when { + expression { + testShouldRunOnAgent( + params.RUN_ONLY, + runSpecificTestOnAgent(params.RUN_ONLY, NESTED_ARRAY_OF_TESTS_TO_RUN[1]) + ) + } + } - stash( - name: 'testResultEE', - includes: ''' - cucumber/*/*.*, - container_logs/*/*, - spec/reports/*.xml, - spec/reports-audit/*.xml, - gems/conjur-rack/spec/reports/*.xml, - cucumber/*/features/reports/**/*.xml - ''' - ) + environment { + CUCUMBER_FILTER_TAGS = "${CucumberFilterTags()}" + } + + steps { + script { + addNewImagesToAgent(INFRAPOOL_EXECUTORV2_RHELEE_AGENT_1) + INFRAPOOL_EXECUTORV2_RHELEE_AGENT_1.agentUnstash name: 'version_info' + runConjurTests( + INFRAPOOL_EXECUTORV2_RHELEE_AGENT_1, + params.RUN_ONLY, + NESTED_ARRAY_OF_TESTS_TO_RUN[1] + ) + } + } + post { + always { + script { + INFRAPOOL_EXECUTORV2_RHELEE_AGENT_1.agentStash( + name: 'testResultEE2', + includes: ''' + cucumber/*/*.*, + container_logs/*/*, + spec/reports/*.xml, + spec/reports-audit/*.xml, + cucumber/*/features/reports/**/*.xml + ''' + ) + } + } + } + } + // Run a subset of tests on a second agent to prevent oversubscribing the hardware + stage('EE FIPS agent3 tests') { + when { + expression { + testShouldRunOnAgent( + params.RUN_ONLY, + runSpecificTestOnAgent(params.RUN_ONLY, NESTED_ARRAY_OF_TESTS_TO_RUN[2]) + ) + } + } + + environment { + CUCUMBER_FILTER_TAGS = "${CucumberFilterTags()}" + } + + steps { + script { + addNewImagesToAgent(INFRAPOOL_EXECUTORV2_RHELEE_AGENT_2) + INFRAPOOL_EXECUTORV2_RHELEE_AGENT_2.agentUnstash name: 'version_info' + runConjurTests( + INFRAPOOL_EXECUTORV2_RHELEE_AGENT_2, + params.RUN_ONLY, + NESTED_ARRAY_OF_TESTS_TO_RUN[2] + ) + } + } + post { + always { + script { + INFRAPOOL_EXECUTORV2_RHELEE_AGENT_2.agentStash( + name: 'testResultEE3', + includes: ''' + cucumber/*/*.*, + container_logs/*/*, + spec/reports/*.xml, + spec/reports-audit/*.xml, + cucumber/*/features/reports/**/*.xml + ''' + ) + } + } + } + } + } + } + } + post { + always { + script { + if (testShouldRunOnAgent(params.RUN_ONLY, runSpecificTestOnAgent(params.RUN_ONLY, NESTED_ARRAY_OF_TESTS_TO_RUN[0]))) { + dir('ee-test'){ + INFRAPOOL_EXECUTORV2_AGENT_0.agentUnstash 'testResultEE' + } + } + if (testShouldRunOnAgent(params.RUN_ONLY, runSpecificTestOnAgent(params.RUN_ONLY, NESTED_ARRAY_OF_TESTS_TO_RUN[1]))) { + dir('ee-test'){ + INFRAPOOL_EXECUTORV2_AGENT_0.agentUnstash 'testResultEE2' + } + } + if (testShouldRunOnAgent(params.RUN_ONLY, runSpecificTestOnAgent(params.RUN_ONLY, NESTED_ARRAY_OF_TESTS_TO_RUN[2]))) { + dir('ee-test'){ + INFRAPOOL_EXECUTORV2_AGENT_0.agentUnstash 'testResultEE3' + } + } + } + + archiveArtifacts( + artifacts: "ee-test/cucumber/*/*.*", + fingerprint: false, + allowEmptyArchive: true + ) + + archiveArtifacts( + artifacts: "ee-test/container_logs/*/*", + fingerprint: false, + allowEmptyArchive: true + ) + + publishHTML( + reportDir: 'ee-test/cucumber', + reportFiles: ''' + api/cucumber_results.html, + authenticators_config/cucumber_results.html, + authenticators_azure/cucumber_results.html, + authenticators_ldap/cucumber_results.html, + authenticators_oidc/cucumber_results.html, + authenticators_jwt/cucumber_results.html, + authenticators_status/cucumber_results.html + policy/cucumber_results.html, + rotators/cucumber_results.html + ''', + reportName: 'EE Integration reports', + reportTitles: '', + allowMissing: false, + alwaysLinkToLastBuild: true, + keepAll: true + ) + } + } + } + + stage('Run environment tests in parallel') { + parallel { + stage('Standard agent tests') { + when { + expression { + testShouldRunOnAgent( + params.RUN_ONLY, + runSpecificTestOnAgent(params.RUN_ONLY, NESTED_ARRAY_OF_TESTS_TO_RUN[0]) + ) + } } + environment { + INFRAPOOL_CUCUMBER_FILTER_TAGS = "${params.CUCUMBER_FILTER_TAGS}" + } + + steps { + script { + INFRAPOOL_EXECUTORV2_AGENT_0.agentSh(script: 'cat /etc/os-release', label: 'Ubuntu version') + INFRAPOOL_EXECUTORV2_AGENT_0.agentSh(script: 'docker --version', label: 'Docker version') + runConjurTests( + INFRAPOOL_EXECUTORV2_AGENT_0, + params.RUN_ONLY, + NESTED_ARRAY_OF_TESTS_TO_RUN[0] + ) + } + } post { always { - dir('ee-test'){ - unstash 'testResultEE' + script { + INFRAPOOL_EXECUTORV2_AGENT_0.agentStash( + name: 'standardTestResult', + includes: ''' + cucumber/*/*.*, + container_logs/*/*, + spec/reports/*.xml, + spec/reports-audit/*.xml, + cucumber/*/features/reports/**/*.xml + ''' + ) } + } + } + } - archiveArtifacts( - artifacts: "ee-test/cucumber/*/*.*", - fingerprint: false, - allowEmptyArchive: true + // Run a subset of tests on a second agent to prevent oversubscribing the hardware + stage('Standard agent2 tests') { + when { + expression { + testShouldRunOnAgent( + params.RUN_ONLY, + runSpecificTestOnAgent(params.RUN_ONLY, NESTED_ARRAY_OF_TESTS_TO_RUN[1]) ) + } + } - archiveArtifacts( - artifacts: "ee-test/container_logs/*/*", - fingerprint: false, - allowEmptyArchive: true - ) + environment { + INFRAPOOL_CUCUMBER_FILTER_TAGS = "${params.CUCUMBER_FILTER_TAGS}" + } - publishHTML( - reportDir: 'ee-test/cucumber', - reportFiles: ''' - api/cucumber_results.html, - authenticators_config/cucumber_results.html, - authenticators_azure/cucumber_results.html, - authenticators_ldap/cucumber_results.html, - authenticators_oidc/cucumber_results.html, - authenticators_jwt/cucumber_results.html, - authenticators_status/cucumber_results.html - policy/cucumber_results.html, - rotators/cucumber_results.html - ''', - reportName: 'EE Integration reports', - reportTitles: '', - allowMissing: false, - alwaysLinkToLastBuild: true, - keepAll: true + steps { + script { + addNewImagesToAgent(INFRAPOOL_EXECUTORV2_AGENT_1) + INFRAPOOL_EXECUTORV2_AGENT_1.agentUnstash name: 'version_info' + runConjurTests( + INFRAPOOL_EXECUTORV2_AGENT_1, + params.RUN_ONLY, + NESTED_ARRAY_OF_TESTS_TO_RUN[1] ) } } + post { + always { + script { + INFRAPOOL_EXECUTORV2_AGENT_1.agentStash( + name: 'standardTestResult2', + includes: ''' + cucumber/*/*.*, + container_logs/*/*, + spec/reports/*.xml, + spec/reports-audit/*.xml, + cucumber/*/features/reports/**/*.xml + ''' + ) + } + } + } } - } - } - stage('Run environment tests in parallel') { - parallel { - stage('Standard agent tests') { + // Run a subset of tests on a second agent to prevent oversubscribing the hardware + stage('Standard agent3 tests') { + when { + expression { + testShouldRunOnAgent( + params.RUN_ONLY, + runSpecificTestOnAgent(params.RUN_ONLY, NESTED_ARRAY_OF_TESTS_TO_RUN[2]) + ) + } + } + environment { - CUCUMBER_FILTER_TAGS = "${params.CUCUMBER_FILTER_TAGS}" + INFRAPOOL_CUCUMBER_FILTER_TAGS = "${params.CUCUMBER_FILTER_TAGS}" } + steps { - sh(script: 'cat /etc/os-release', label: 'RHEL version') - sh(script: 'docker --version', label: 'Docker version') - runConjurTests(params.RUN_ONLY) + script { + addNewImagesToAgent(INFRAPOOL_EXECUTORV2_AGENT_2) + INFRAPOOL_EXECUTORV2_AGENT_2.agentUnstash name: 'version_info' + runConjurTests( + INFRAPOOL_EXECUTORV2_AGENT_2, + params.RUN_ONLY, + NESTED_ARRAY_OF_TESTS_TO_RUN[2] + ) + } + } + post { + always { + script { + INFRAPOOL_EXECUTORV2_AGENT_2.agentStash( + name: 'standardTestResult3', + includes: ''' + cucumber/*/*.*, + container_logs/*/*, + spec/reports/*.xml, + spec/reports-audit/*.xml, + cucumber/*/features/reports/**/*.xml, + ci/test_suites/*/output/* + ''' + ) + } + } } } @@ -378,38 +638,37 @@ pipeline { testShouldRun(params.RUN_ONLY, "azure_authenticator") } } - - agent { label 'azure-linux' } - environment { // TODO: Move this into the authenticators_azure bash script. - AZURE_AUTHN_INSTANCE_IP = sh( + INFRAPOOL_AZURE_AUTHN_INSTANCE_IP = INFRAPOOL_AZURE_EXECUTORV2_AGENT_0.agentSh( script: 'curl "http://checkip.amazonaws.com"', returnStdout: true ).trim() // TODO: Move this into the authenticators_azure bash script. - SYSTEM_ASSIGNED_IDENTITY = sh( - script: 'ci/test_suites/authenticators_azure/' + - 'get_system_assigned_identity.sh', + INFRAPOOL_SYSTEM_ASSIGNED_IDENTITY = INFRAPOOL_AZURE_EXECUTORV2_AGENT_0.agentSh( + script: 'ci/test_suites/authenticators_azure/get_system_assigned_identity.sh', returnStdout: true ).trim() } steps { - unstash 'version_info' - // Grant access to this Jenkins agent's IP to AWS security groups - // This is required for access to the internal docker registry - // from outside EC2. - grantIPAccess() - sh( - 'summon -f ci/test_suites/authenticators_azure/secrets.yml ' + - 'ci/test authenticators_azure' - ) + script { + grantIPAccess(INFRAPOOL_AZURE_EXECUTORV2_AGENT_0) + addNewImagesToAgent(INFRAPOOL_AZURE_EXECUTORV2_AGENT_0) + INFRAPOOL_AZURE_EXECUTORV2_AGENT_0.agentUnstash name: 'version_info' + // Grant access to this Jenkins agent's IP to AWS security groups + // This is required for access to the internal docker registry + // from outside EC2. + INFRAPOOL_AZURE_EXECUTORV2_AGENT_0.agentSh( + 'summon -f ci/test_suites/authenticators_azure/secrets.yml ci/test authenticators_azure' + ) + } } post { always { - stash( + script { + INFRAPOOL_AZURE_EXECUTORV2_AGENT_0.agentStash( name: 'testResultAzure', allowEmpty: true, includes: ''' @@ -421,7 +680,8 @@ pipeline { // Remove this Agent's IP from IPManager's prefix list // There are a limited number of entries, so it remove it // rather than waiting for it to expire. - removeIPAccess() + removeIPAccess(INFRAPOOL_AZURE_EXECUTORV2_AGENT_0) + } } } } @@ -446,7 +706,7 @@ pipeline { script { dir('ci/test_suites/authenticators_gcp') { - stash( + INFRAPOOL_GCP_EXECUTORV2_AGENT_0.agentStash( name: 'get_gce_tokens_script', includes: ''' get_gce_tokens_to_files.sh, @@ -456,25 +716,22 @@ pipeline { ) } - node('executor-v2-gcp-small') { - echo '-- Google Compute Engine allocated' - echo '-- Get compute engine instance project name from ' + - 'Google metadata server.' - // TODO: Move this into get_gce_tokens_to_files.sh - env.GCP_PROJECT = sh( - script: 'curl -s -H "Metadata-Flavor: Google" ' + - '"http://metadata.google.internal/computeMetadata/v1/' + - 'project/project-id"', - returnStdout: true - ).trim() - unstash('get_gce_tokens_script') - sh('./get_gce_tokens_to_files.sh') - stash( - name: 'authnGceTokens', - includes: 'gce_token_*', - allowEmpty:false - ) - } + echo '-- Google Compute Engine allocated' + echo '-- Get compute engine instance project name from ' + + 'Google metadata server.' + // TODO: Move this into get_gce_tokens_to_files.sh + env.INFRAPOOL_GCP_PROJECT = INFRAPOOL_GCP_EXECUTORV2_AGENT_0.agentSh( + script: 'curl -s -H "Metadata-Flavor: Google" \ + "http://metadata.google.internal/computeMetadata/v1/project/project-id"', + returnStdout: true + ).trim() + INFRAPOOL_GCP_EXECUTORV2_AGENT_0.agentUnstash(name: 'get_gce_tokens_script') + INFRAPOOL_GCP_EXECUTORV2_AGENT_0.agentSh('./get_gce_tokens_to_files.sh') + INFRAPOOL_GCP_EXECUTORV2_AGENT_0.agentStash( + name: 'authnGceTokens', + includes: 'gce_token_*', + allowEmpty:false + ) } } post { @@ -510,9 +767,9 @@ pipeline { } } environment { - GCP_FETCH_TOKEN_FUNCTION = "fetch_token_${BUILD_NUMBER}" - IDENTITY_TOKEN_FILE = 'identity-token' - GCP_OWNER_SERVICE_KEY_FILE = "sa-key-file.json" + INFRAPOOL_GCP_FETCH_TOKEN_FUNCTION = "fetch_token_${Math.abs(new Random().nextInt(100000))}" + INFRAPOOL_IDENTITY_TOKEN_FILE = 'identity-token' + INFRAPOOL_GCP_OWNER_SERVICE_KEY_FILE = "sa-key-file.json" } steps { echo "Waiting for GCP project name (Set by stage: " + @@ -521,7 +778,7 @@ pipeline { waitUntil { script { return ( - env.GCP_PROJECT != null || env.GCP_ENV_ERROR == "true" + env.INFRAPOOL_GCP_PROJECT != null || env.GCP_ENV_ERROR == "true" ) } } @@ -532,7 +789,7 @@ pipeline { } dir('ci/test_suites/authenticators_gcp') { - sh('summon ./deploy_function_and_get_tokens.sh') + INFRAPOOL_EXECUTORV2_AGENT_0.agentSh('summon ./deploy_function_and_get_tokens.sh') } } } @@ -552,7 +809,7 @@ pipeline { always { script { dir('ci/test_suites/authenticators_gcp') { - sh ''' + INFRAPOOL_EXECUTORV2_AGENT_0.agentSh ''' # Cleanup Google function summon ./run_gcloud.sh cleanup_function.sh ''' @@ -601,44 +858,108 @@ pipeline { } script { dir('ci/test_suites/authenticators_gcp/tokens') { - unstash 'authnGceTokens' + INFRAPOOL_EXECUTORV2_AGENT_0.agentUnstash name: 'authnGceTokens' } - sh 'ci/test authenticators_gcp' + INFRAPOOL_EXECUTORV2_AGENT_0.agentSh 'ci/test authenticators_gcp' } } } } } - } - post { - success { - script { - if (env.BRANCH_NAME == 'master') { - build( - job:'../cyberark--secrets-provider-for-k8s/main', - wait: false - ) + stage('Promote image to Conjur Dev ECR') { + steps { + script { + withCredentials([ + conjurSecretCredential(credentialsId: "RnD-Global-Conjur-Ent-Conjur_dev-conjur-ci-user-conjur_awsaccesskeyid", variable: 'AWS_ACCESS_KEY_ID'), + conjurSecretCredential(credentialsId: "RnD-Global-Conjur-Ent-Conjur_dev-conjur-ci-user-conjur_password", variable: 'AWS_SECRET_ACCESS_KEY')]) { + conjurVersion = versionsUtils.getNextVersion("minor") + env.INFRAPOOL_AWS_ACCESS_KEY_ID = env.AWS_ACCESS_KEY_ID + env.INFRAPOOL_AWS_SECRET_ACCESS_KEY = env.AWS_SECRET_ACCESS_KEY + INFRAPOOL_EXECUTORV2_AGENT_0.agentSh "./publish-images.sh --ecr --version=${conjurVersion}" + } + } + } + } + + stage('Promote image to staging') { + when { + expression { isMain } + expression { !isStartedByTimer} + } + steps { + script { + withCredentials([ + conjurSecretCredential(credentialsId: "RnD-Global-Conjur-Ent-Conjur_dev-conjur-ci-user-conjur_awsaccesskeyid", variable: 'AWS_ACCESS_KEY_ID'), + conjurSecretCredential(credentialsId: "RnD-Global-Conjur-Ent-Conjur_dev-conjur-ci-user-conjur_password", variable: 'AWS_SECRET_ACCESS_KEY')]) { + env.INFRAPOOL_AWS_ACCESS_KEY_ID = env.AWS_ACCESS_KEY_ID + env.INFRAPOOL_AWS_SECRET_ACCESS_KEY = env.AWS_SECRET_ACCESS_KEY + conjurCloudCiManager.promoteImage(devEcr, mainEcr, conjurVersion, false, "stable", null, false) + } } } } + stage("Tag main branch") { + when { + expression { isMain } + expression { !isStartedByTimer} + } + steps { + script { + versionsUtils.tagNextVersion("minor") + } + } + } + } + + post { always { script { + if (testShouldRunOnAgent(params.RUN_ONLY, runSpecificTestOnAgent(params.RUN_ONLY, NESTED_ARRAY_OF_TESTS_TO_RUN[0]))) { + unstash 'standardTestResult' + } + + if (testShouldRunOnAgent(params.RUN_ONLY, runSpecificTestOnAgent(params.RUN_ONLY, NESTED_ARRAY_OF_TESTS_TO_RUN[1]))) { + unstash 'standardTestResult2' + INFRAPOOL_EXECUTORV2_AGENT_0.agentUnstash 'standardTestResult2' + } + + if (testShouldRunOnAgent(params.RUN_ONLY, runSpecificTestOnAgent(params.RUN_ONLY, NESTED_ARRAY_OF_TESTS_TO_RUN[2]))) { + unstash 'standardTestResult3' + INFRAPOOL_EXECUTORV2_AGENT_0.agentUnstash 'standardTestResult3' + } + // Only unstash azure if it ran. if (testShouldRun(params.RUN_ONLY, "azure_authenticator")) { unstash 'testResultAzure' + INFRAPOOL_EXECUTORV2_AGENT_0.agentUnstash 'testResultAzure' } + INFRAPOOL_EXECUTORV2_AGENT_0.agentStash( + name: 'coverage-reports', + includes: ''' + cucumber/*/*.*, + container_logs/*/*, + spec/reports/*.xml, + spec/reports-audit/*.xml, + gems/conjur-rack/spec/reports/*.xml, + gems/slosilo/spec/reports/*.xml, + cucumber/*/features/reports/**/*.xml, + coverage/* + ''' + ) + unstash 'coverage-reports' + // Make files available for download. archiveFiles('container_logs/*/*') archiveFiles('coverage/.resultset*.json') archiveFiles('coverage/coverage.json') archiveFiles('coverage/codeclimate.json') - archiveFiles( - 'ci/test_suites/authenticators_k8s/output/simplecov-resultset-authnk8s-gke.json' - ) +// archiveFiles( +// 'ci/test_suites/authenticators_k8s/output/simplecov-resultset-authnk8s-gke.json' +// ) archiveFiles('cucumber/*/*.*') publishHTML([ @@ -676,10 +997,12 @@ pipeline { spec/reports/*.xml, spec/reports-audit/*.xml, gems/conjur-rack/spec/reports/*.xml, + gems/slosilo/spec/reports/*.xml cucumber/*/features/reports/**/*.xml, ee-test/spec/reports/*.xml, ee-test/spec/reports-audit/*.xml, ee-test/gems/conjur-rack/spec/reports/*.xml, + ee-test/gems/slosilo/spec/reports/*.xml, ee-test/cucumber/*/features/reports/**/*.xml ''' ) @@ -701,31 +1024,14 @@ pipeline { } } steps{ - sh 'ci/submit-coverage' - } - } - - stage("Release Conjur images and packages") { - when { - expression { - MODE == "RELEASE" - } - } - steps { - release { billOfMaterialsDirectory, assetDirectory -> - // Publish docker images - sh './publish-images.sh --edge --dockerhub' - - // Create deb and rpm packages - sh 'echo "CONJUR_VERSION=5" >> debify.env' - sh './package.sh' - archiveArtifacts artifacts: '*.deb', fingerprint: true - archiveArtifacts artifacts: '*.rpm', fingerprint: true - sh "cp *.rpm ${assetDirectory}/." - sh "cp *.deb ${assetDirectory}/." - - // Publish deb and rpm packages - sh './publish.sh' + script { + INFRAPOOL_EXECUTORV2_AGENT_0.agentSh 'ci/submit-coverage' + INFRAPOOL_EXECUTORV2_AGENT_0.agentStash name: 'coverage', includes: 'coverage/**' + unstash 'coverage' + archiveFiles('coverage/*.xml') + retry(2) { + codacy action: 'reportCoverage', filePath: "coverage/coverage.xml" + } } } } @@ -733,14 +1039,20 @@ pipeline { post { always { - // Explanation of arguments: - // cleanupAndNotify(buildStatus, slackChannel, additionalMessage, ticket) - cleanupAndNotify( - currentBuild.currentResult, - '#conjur-core', - "${(params.NIGHTLY ? 'nightly' : '')}", - true - ) + releaseInfraPoolAgent(".infrapool/release_agents") + script { + if (isStartedByTimer) { + notifyBuildStatus("conjur_cloud") + } + } + } + failure { + script { + if (env.BRANCH_NAME == "conjur-cloud" && !isStartedByTimer) { + notifyBuildStatus("conjur_cloud") + } + } + emailNotification(project: "conjur-cloud", branchName: "conjur-cloud") } } } @@ -751,12 +1063,22 @@ pipeline { // TODO: Do we want to move any of these functions to a separate file? +def addNewImagesToAgent(infrapool) { + // Pull and retag existing images onto new Jenkins agent + infrapool.agentSh """ + docker pull registry.tld/conjur-cloud:${tagWithSHA()} + docker pull registry.tld/conjur-test:${tagWithSHA()} + docker tag registry.tld/conjur-cloud:${tagWithSHA()} conjur-cloud:${tagWithSHA()} + docker tag registry.tld/conjur-test:${tagWithSHA()} conjur-test:${tagWithSHA()} + """ +} + // Possible minor optimization: Could memoize this. Need to verify it's not // shared across builds. def tagWithSHA() { sh( returnStdout: true, - script: 'echo $(git rev-parse --short=8 HEAD)' + script: 'echo -n $(git rev-parse --short=8 HEAD)' ) } @@ -769,82 +1091,136 @@ def archiveFiles(filePattern) { } def testShouldRun(run_only_str, test) { + //If its sanity or smoke run we shouldn't run the tests + if (params.CUCUMBER_FILTER_TAGS == '@smoke' || params.CUCUMBER_FILTER_TAGS == '@sanity') { + return false + } return run_only_str == '' || run_only_str.split().contains(test) } -// "run_only_str" is a space-separated string specifying the subset of tests to -// run. If it's empty, all tests are run. -def runConjurTests(run_only_str) { +def testShouldRunOnAgent(run_only_str, agent_specific_tests) { + return run_only_str == '' || ! agent_specific_tests.isEmpty() +} - all_tests = [ +def runSpecificTestOnAgent(run_only_str, agent_specific_tests) { + // runSpecificTestOnAgent allows a subset of tests to be ran + // on an agent, determined by the agent's assigned subset of + // tests it normally runs. + + // Args: + // run_only_str: a space seperated string of test names + // agent_specific_tests: an array of tests that the agent + // is assigned to run + + // Returns: + // An array of test names to run + def run_only_tests = [] + def find_tests = run_only_str.split() + + find_tests.each { run_only_test -> + agent_specific_tests.find { agent_test -> + if (agent_test.contains(run_only_test)) { + run_only_tests.add(run_only_test) + } + } + } + return run_only_tests +} + +def conjurTests(infrapool) { + return [ "authenticators_config": [ "Authenticators Config - ${env.STAGE_NAME}": { - sh 'ci/test authenticators_config' + infrapool.agentSh 'ci/test authenticators_config' } ], "authenticators_status": [ "Authenticators Status - ${env.STAGE_NAME}": { - sh 'ci/test authenticators_status' - } - ], - "authenticators_k8s": [ - "K8s Authenticator - ${env.STAGE_NAME}": { - sh 'ci/test authenticators_k8s' - } - ], - "authenticators_ldap": [ - "LDAP Authenticator - ${env.STAGE_NAME}": { - sh 'ci/test authenticators_ldap' + infrapool.agentSh 'ci/test authenticators_status' } ], +// "authenticators_ldap": [ +// "LDAP Authenticator - ${env.STAGE_NAME}": { +// sh 'ci/test authenticators_ldap' +// } +// ], "authenticators_oidc": [ "OIDC Authenticator - ${env.STAGE_NAME}": { - sh 'summon -f ./ci/test_suites/authenticators_oidc/secrets.yml -e ci ci/test authenticators_oidc' + withCredentials([ + conjurSecretCredential(credentialsId: "RnD-Global-Conjur-Ent-Conjur_Operating_System-WindowsDomainAccountDailyRotation-cyberng.com-svc_cnjr_enterprise_username", variable: 'INFRAPOOL_IDENTITY_USERNAME'), + conjurSecretCredential(credentialsId: "RnD-Global-Conjur-Ent-Conjur_Operating_System-WindowsDomainAccountDailyRotation-cyberng.com-svc_cnjr_enterprise_password", variable: 'INFRAPOOL_IDENTITY_PASSWORD') + ]) + { + infrapool.agentSh 'summon -f ./ci/test_suites/authenticators_oidc/secrets.yml -e ci ci/test authenticators_oidc' + } } ], "authenticators_jwt": [ "JWT Authenticator - ${env.STAGE_NAME}": { - sh 'ci/test authenticators_jwt' + infrapool.agentSh 'ci/test authenticators_jwt' } ], "policy": [ "Policy - ${env.STAGE_NAME}": { - sh 'ci/test policy' + infrapool.agentSh 'ci/test policy' } ], "api": [ "API - ${env.STAGE_NAME}": { - sh 'ci/test api' + infrapool.agentSh 'ci/test api' } ], "rotators": [ "Rotators - ${env.STAGE_NAME}": { - sh 'ci/test rotators' + infrapool.agentSh 'ci/test rotators' } ], +// "authenticators_k8s": [ +// "K8s Authenticator - ${env.STAGE_NAME}": { +// sh 'ci/test authenticators_k8s' +// } +// ], "rspec_audit": [ "Audit - ${env.STAGE_NAME}": { - sh 'ci/test rspec_audit' + infrapool.agentSh 'ci/test rspec_audit' } ], "policy_parser": [ "Policy Parser - ${env.STAGE_NAME}": { - sh 'cd gems/policy-parser && ./test.sh' + infrapool.agentSh 'cd gems/policy-parser && ./test.sh' } ], "conjur_rack": [ "Rack - ${env.STAGE_NAME}": { - sh 'cd gems/conjur-rack && ./test.sh' + infrapool.agentSh 'cd gems/conjur-rack && ./test.sh' + } + ], + "slosilo": [ + "Slosilo - ${env.STAGE_NAME}": { + infrapool.agentSh 'cd gems/slosilo && ./test.sh' } ] ] +} + +def runConjurTests(infrapool, run_only_str, cuke_test_names) { + // runConjurTests will build a parallel Jenkins block of code + // that will run the specified cucumber test stages. - // Filter for the tests we want run, if requested. - parallel_tests = all_tests - tests = run_only_str.split() + // Args: + // cuke_test_names an array of test names to run. - if (tests.size() > 0) { - parallel_tests = all_tests.subMap(tests) + // Returns: + // A Jenkins block of parallel code. + + def all_tests = conjurTests(infrapool) + def run_only_tests = runSpecificTestOnAgent(run_only_str, cuke_test_names) + def parallel_tests = all_tests + + if (run_only_tests.isEmpty()) { + parallel_tests = all_tests.subMap(cuke_test_names) + } else { + parallel_tests = all_tests.subMap(run_only_tests) } // Create the parallel pipeline. @@ -852,6 +1228,7 @@ def runConjurTests(run_only_str) { // Since + merges two maps together, sum() combines the individual values of // parallel_tests into one giant map whose keys are the stage names and // whose values are the blocks to be run. + script { parallel( parallel_tests.values().sum() @@ -859,13 +1236,55 @@ def runConjurTests(run_only_str) { } } +def collateTests(infrapool, jobs_per_agent=4) { + // collateTests will find the names of cucumber tests that should run + // and create a nested list of tests to be ran across mutliple Jenkins + // agents. + + // Args: + // jobs_per_agent: The nested list of tests names will be no more than + // the specified integer. + + // Returns: a nested list of test names. + + def all_tests = conjurTests(infrapool) + def all_test_names = [] + + all_tests.each{ k, _ -> + all_test_names.add(k) + } + + def parallel_tests = [] + // Create a subset of tests that can be ran by each Jenkins agent + int partitionCount = all_test_names.size() / jobs_per_agent + + partitionCount.times { partitionNumber -> + def start = partitionNumber * jobs_per_agent + def end = start + jobs_per_agent - 1 + parallel_tests.add(all_test_names[start..end]) + } + + if (all_tests.size() % jobs_per_agent) { + parallel_tests.add(all_test_names[partitionCount * jobs_per_agent..-1]) + } + return parallel_tests +} + def defaultCucumberFilterTags(env) { - if(env.BRANCH_NAME == 'master' || env.TAG_NAME?.trim()) { - // If this is a master or tag build, we want to run all of the tests. So - // we use an empty filter string. - return '' + echo "current build description: ${currentBuild.getBuildCauses()[0]["shortDescription"]}" + + if(env.BRANCH_NAME == 'conjur-cloud') { + // This is nightly build + if (isStartedByTimer) { + echo 'In nightly build run all tests' + return '' + } + // If this is a conjur-cloud master we want to run smoke tests + echo 'Setting cucumber tests to smoke' + return '@smoke' } - // For all other branch builds, only run the @smoke tests by default - return '@smoke' + // For all other branch builds, only run the @sanity tests by default + echo 'Setting cucumber tests to sanity' + return '@sanity' } diff --git a/NOTICES.txt b/NOTICES.txt index 9f776f0a92..bf9aa0f610 100644 --- a/NOTICES.txt +++ b/NOTICES.txt @@ -8,50 +8,51 @@ of the license associated with each component. Section 1: Apache-2.0 ->>> https://rubygems.org/gems/aws-sdk-iam/versions/1.66.0 +>>> https://rubygems.org/gems/aws-sdk-iam/versions/1.81.0 >>> https://rubygems.org/gems/gli/versions/2.21.0 +>>> https://rubygems.org/gems/prometheus-client/versions/3.0.0 Section 2: BSD-2-Clause ->>> https://rubygems.org/gems/pg/versions/1.2.3 +>>> https://rubygems.org/gems/pg/versions/1.5.3 >>> https://rubygems.org/gems/websocket/versions/1.2.9 Section 3: BSD-3-Clause >>> https://rubygems.org/gems/base32-crockford/versions/0.1.0 ->>> https://rubygems.org/gems/ffi/versions/1.15.4 ->>> https://rubygems.org/gems/puma/versions/5.6.4 +>>> https://rubygems.org/gems/ffi/versions/1.15.5 +>>> https://rubygems.org/gems/puma/versions/6.3.1 Section 4: MIT ->>> https://rubygems.org/gems/activesupport/versions/6.1.7.3 ->>> https://rubygems.org/gems/anyway_config/versions/2.2.3 +>>> https://rubygems.org/gems/activesupport/versions/6.1.7.4 +>>> https://rubygems.org/gems/anyway_config/versions/2.4.2 >>> https://rubygems.org/gems/base58/versions/0.2.3 ->>> https://rubygems.org/gems/bcrypt/versions/3.1.16 +>>> https://rubygems.org/gems/bcrypt/versions/3.1.19 >>> https://rubygems.org/gems/command_class/versions/0.0.2 >>> https://rubygems.org/gems/conjur-policy-parser/versions/3.0.4 >>> https://rubygems.org/gems/conjur-rack/versions/5.0.0 >>> https://rubygems.org/gems/conjur-rack-heartbeat/versions/2.2.0 ->>> https://rubygems.org/gems/dry-struct/versions/1.4.0 ->>> https://rubygems.org/gems/dry-types/versions/1.5.1 +>>> https://rubygems.org/gems/dry-struct/versions/1.6.0 +>>> https://rubygems.org/gems/dry-types/versions/1.7.1 >>> https://rubygems.org/gems/http/versions/4.2.0 >>> https://rubygems.org/gems/iso8601/versions/0.13.0 >>> https://rubygems.org/gems/jbuilder/versions/2.7.0 ->>> https://rubygems.org/gems/jwt/versions/2.2.2 ->>> https://rubygems.org/gems/kubeclient/versions/4.9.3 ->>> https://rubygems.org/gems/listen/versions/3.7.0 ->>> https://rubygems.org/gems/loofah/versions/2.20.0 ->>> https://rubygems.org/gems/net-ldap/versions/0.17.0 ->>> https://rubygems.org/gems/nokogiri/versions/1.14.3 ->>> https://rubygems.org/gems/openid_connect/versions/1.3.0 +>>> https://rubygems.org/gems/jwt/versions/2.7.1 +>>> https://rubygems.org/gems/kubeclient/versions/4.11.0 +>>> https://rubygems.org/gems/listen/versions/3.8.0 +>>> https://rubygems.org/gems/loofah/versions/2.21.3 +>>> https://rubygems.org/gems/net-ldap/versions/0.18.0 +>>> https://rubygems.org/gems/nokogiri/versions/1.15.3-x86_64-darwin +>>> https://rubygems.org/gems/openid_connect/versions/2.2.0 >>> https://rubygems.org/gems/rack-rewrite/versions/1.5.1 ->>> https://rubygems.org/gems/rails/versions/6.1.7.3 +>>> https://rubygems.org/gems/rails/versions/6.1.7.4 >>> https://rubygems.org/gems/rake/versions/13.0.6 ->>> https://rubygems.org/gems/sequel/versions/5.51.0 +>>> https://rubygems.org/gems/sequel/versions/5.69.0 >>> https://rubygems.org/gems/sequel-pg_advisory_locking/versions/1.0.1 >>> https://rubygems.org/gems/sequel-postgres-schemata/versions/0.1.3 ->>> https://rubygems.org/gems/sequel-rails/versions/1.1.1 ->>> https://rubygems.org/gems/simplecov/versions/0.21.2 +>>> https://rubygems.org/gems/sequel-rails/versions/1.2.0 +>>> https://rubygems.org/gems/simplecov/versions/0.22.0 >>> https://rubygems.org/gems/slosilo/versions/3.0.1 >>> https://rubygems.org/gems/event_emitter/versions/0.2.6 @@ -66,7 +67,7 @@ APPENDIX: Standard License Files and Templates Apache-2.0 License is applicable to the following component(s). ->>> https://rubygems.org/gems/aws-sdk-iam/versions/1.66.0 +>>> https://rubygems.org/gems/aws-sdk-iam/versions/1.81.0 Copyright 2011-2014 Amazon.com, Inc. or its affiliates. All Rights Reserved. @@ -98,11 +99,27 @@ 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. +>> https://rubygems.org/gems/prometheus-client/versions/3.0.0 + +Copyright [yyyy] [name of copyright owner] + +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. + --------------- SECTION 2: BSD-2-Clause ---------- BSD-2-Clause License is applicable to the following component(s). ->>> https://rubygems.org/gems/pg/versions/1.2.3 +>>> https://rubygems.org/gems/pg/versions/1.5.3 Copyright © 1997-2019 by the authors. @@ -185,7 +202,7 @@ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ->>> https://rubygems.org/gems/ffi/versions/1.15.4 +>>> https://rubygems.org/gems/ffi/versions/1.15.5 Copyright (c) 2008-2016, Ruby FFI project contributors @@ -214,7 +231,7 @@ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ->>> https://rubygems.org/gems/puma/versions/5.6.4 +>>> https://rubygems.org/gems/puma/versions/6.3.1 Some code copyright (c) 2005, Zed Shaw Copyright (c) 2011, Evan Phoenix @@ -247,7 +264,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. MIT License is applicable to the following component(s). ->>> https://rubygems.org/gems/activesupport/versions/6.1.7.3 +>>> https://rubygems.org/gems/activesupport/versions/6.1.7.4 Copyright (c) 2005-2018 David Heinemeier Hansson @@ -269,7 +286,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ->>> https://rubygems.org/gems/anyway_config/versions/2.2.3 +>>> https://rubygems.org/gems/anyway_config/versions/2.4.2 Copyright (c) 2015-2020 Vladimir Dementyev @@ -316,7 +333,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ->>> https://rubygems.org/gems/bcrypt/versions/3.1.16 +>>> https://rubygems.org/gems/bcrypt/versions/3.1.19 Copyright 2007-2011: @@ -436,7 +453,7 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ->>> https://rubygems.org/gems/dry-struct/versions/1.4.0 +>>> https://rubygems.org/gems/dry-struct/versions/1.6.0 Copyright (c) 2013-2016 Piotr Solnica @@ -458,7 +475,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ->>> https://rubygems.org/gems/dry-types/versions/1.5.1 +>>> https://rubygems.org/gems/dry-types/versions/1.7.1 Copyright (c) 2013-2014 Piotr Solnica @@ -546,7 +563,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ->>> https://rubygems.org/gems/jwt/versions/2.2.2 +>>> https://rubygems.org/gems/jwt/versions/2.7.1 Copyright (c) 2011 Jeff Lindsay @@ -567,7 +584,7 @@ HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTIO OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ->>> https://rubygems.org/gems/kubeclient/versions/4.9.3 +>>> https://rubygems.org/gems/kubeclient/versions/4.11.0 Copyright (c) 2014 Alissa Bonas @@ -592,7 +609,7 @@ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ->>> https://rubygems.org/gems/listen/versions/3.7.0 +>>> https://rubygems.org/gems/listen/versions/3.8.0 Copyright (c) 2013 Thibaud Guillaume-Gentil @@ -614,7 +631,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ->>> https://rubygems.org/gems/loofah/versions/2.20.0 +>>> https://rubygems.org/gems/loofah/versions/2.21.3 Copyright (c) 2009 -- 2018 by Mike Dalessio, Bryan Helmkamp @@ -636,7 +653,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ->>> https://rubygems.org/gems/net-ldap/versions/0.17.0 +>>> https://rubygems.org/gems/net-ldap/versions/0.18.0 Copyright 2006–2011 by Francis Cianfrocca and other contributors. @@ -658,7 +675,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ->>> https://rubygems.org/gems/nokogiri/versions/1.14.3 +>>> https://rubygems.org/gems/nokogiri/versions/1.15.3-x86_64-darwin Copyright 2008 -- 2018 by Aaron Patterson, Mike Dalessio, Charles Nutter, Sergio Arbeo, Patrick Mahoney, Yoko Harada, Akinori MUSHA, John Shahid, Lars Kanis @@ -680,7 +697,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ->>> https://rubygems.org/gems/openid_connect/versions/1.3.0 +>>> https://rubygems.org/gems/openid_connect/versions/2.2.0 Copyright (c) 2011 nov matake @@ -726,7 +743,7 @@ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ->>> https://rubygems.org/gems/rails/versions/6.1.7.3 +>>> https://rubygems.org/gems/rails/versions/6.1.7.4 Copyright (c) 2005-2018 David Heinemeier Hansson @@ -770,7 +787,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ->>> >>> https://rubygems.org/gems/sequel/versions/5.51.0 +>>> >>> https://rubygems.org/gems/sequel/versions/5.69.0 Copyright (c) 2007-2008 Sharon Rosner Copyright (c) 2008-2023 Jeremy Evans @@ -836,7 +853,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ->>> https://rubygems.org/gems/sequel-rails/versions/1.1.1 +>>> https://rubygems.org/gems/sequel-rails/versions/1.2.0 Copyright (c) 2009-2013 The sequel-rails team @@ -858,7 +875,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ->>> https://rubygems.org/gems/simplecov/versions/0.21.2 +>>> https://rubygems.org/gems/simplecov/versions/0.22.0 Copyright (c) 2010-2015 Christoph Olszowka diff --git a/TELEMETRY.md b/TELEMETRY.md new file mode 100644 index 0000000000..08dbbf9366 --- /dev/null +++ b/TELEMETRY.md @@ -0,0 +1,172 @@ +# Conjur Telemetry + +Conjur provides a configurable telemetry feature built on +[Prometheus](https://prometheus.io/), which is the preferred open source +monitoring tool for Cloud Native applications. When enabled, it will capture +performance and usage metrics of the running Conjur instance. These metrics are +exposed via a REST endpoint (/metrics) where Prometheus can scrape the data and +archive it as a queryable time series. This increases the observability of a +running Conjur instance and allows for easy integration with popular +visualization and monitoring tools. + +## Metrics + +This implementation leverages the following supported metric types via the +[Prometheus Ruby client library](https://github.com/prometheus/client_ruby): +| Type | Description +| --- | ----------- | +| counter | A cumulative metric that represents a single monotonically increasing counter whose value can only increase or be reset to zero on restart. | +| gauge | A metric that represents a single numerical value that can arbitrarily go up and down. | +| histogram | A metric which samples observations (usually things like request durations or response sizes) and counts them in configurable buckets. | + +See the [Prometheus docs](https://prometheus.io/docs/concepts/metric_types/) for +more on supported metric types. + +### Defined Metrics + +The following metrics are provided with this implementation and will be captured +by default when telemetry is enabled: +| Metric | Type | Description | Labels\* | +| - | - | - | -| +| conjur_http_server_request_exceptions_total| counter | Total number of exceptions that have occured in Conjur during API requests. | operation, exception, message | +| conjur_http_server_requests_total | counter | Total number of API requests handled Conjur and resulting response codes. | operation, code | +| conjur_http_server_request_duration_seconds | histogram | Time series data of API request durations. | operation | +| conjur_server_authenticator | gauge | Number of authenticators installed, configured, and enabled. | type, status | +| conjur_resource_count | counter | Number of resources in the Conjur database. | kind | +| conjur_role_count | counter | Number of roles in the Conjur database. | kind | + +\*Labels are the identifiers by which metrics are logically grouped. For example +`conjur_http_server_requests_total` with the labels `operation` and `code` may +appear like so in the metrics registry: + +```txt +conjur_http_server_requests_total{code="200",operation="getAccessToken"} 1.0 +conjur_http_server_requests_total{code="201",operation="loadPolicy"} 1502.0 +conjur_http_server_requests_total{code="409",operation="loadPolicy"} 1498.0 +conjur_http_server_requests_total{code="401",operation="loadPolicy"} 327.0 +conjur_http_server_requests_total{code="200",operation="getMetrics"} 60.0 +conjur_http_server_requests_total{code="401",operation="unknown"} 62.0 +``` + +This registry format is consistent with the [data model for Prometheus +metrics](https://prometheus.io/docs/concepts/data_model/). + +## Configuration + +### Enabling Metrics Collection + +Metrics telemetry is off by default. It can be enabled in the following ways, +consistent with Conjur's usage of [Anyway Config](https://github.com/palkan/anyway_config): + +| **Name** | **Type** | **Default** | **Required?** | +|----------|----------|-------------|---------------| +| CONJUR_TELEMETRY_ENABLED | Env variable | None | No | +| telemetry_enabled | Key in Config file | None | No | + +Starting Conjur with either of the above configurations set to `true` will result +in initialization of the telemetry feature. + +### Metrics Storage + +Metrics are stored in the Prometheus client store, which is to say they are +stored on the volume of the container running Conjur. The default path for this +is `/tmp/prometheus` but a custom path can also be read in from the environment +variable `CONJUR_METRICS_DIR` on initialization. + +When Prometheus is running alongside Conjur, it can be configured to +periodically scrape metric values via the `/metrics` endpoint. It will keep a +time series of the configured metrics and store this data in a queryable +[on-disk database](https://prometheus.io/docs/prometheus/latest/storage/). See +[prometheus.yml](https://github.com/cyberark/conjur/dev/files/prometheus/prometheus.yml) +for a sample Prometheus config with Conjur as a scrape target. + +## Instrumenting New Metrics + +The following represents a high-level pattern which can be replicated to +instrument new Conjur metrics. Since the actual implementation will vary based +on the type of metric, how the pub/sub event should be instrumented, etc. it is +best to review the existing examples and determine the best approach on a +case-by-case basis. + +1. Create a metric class under the Monitoring::Metrics module (see + `/lib/monitoring/metrics` for examples) +1. Implement `setup(registry, pubsub)` method + 1. Initialize the metric by setting instance variables defining the metric + name, description, labels, etc. + 1. Expose the above instance variables via an attribute reader + 1. Register the metric by calling `Metrics.create_metric(self, :type)` where + type can be `counter`, `gauge`, or `histogram` +1. Implement `update` method to define update behavior + 1. Get the metric from the registry + 1. Determine the label values + 1. Determine and set the metric values +1. Implement a publishing event* + 1. Determine where in the code an event should be triggered which updates + the metric + 1. Use the PubSub singleton class to instrument the correct event i.e. + `Monitoring::PubSub.instance.publish('conjur.policy_loaded')` +1. Add the newly-defined metric to Prometheus initializer + (`/config/initializers/prometheus.rb`) + +*Since instrumenting Pub/Sub events may involve modifying existing code, it +should be as unintrusive as possible. For example, the existing metrics use the +following two methods to avoid modifying any Conjur behavior or impacting +performance: + +* For HTTP requests - instrument the `conjur.request` from the middleware layer +so it does not require changes to Conjur code +* For Policy loading - instrument the `conjur.policy_loaded` event using an +`after_action` hook, which avoids modifying any controller methods + +## Security + +Prometheus supports either an unprotected `/metrics` endpoint, or [basic auth +via the scrape +config](https://prometheus.io/docs/prometheus/latest/configuration/configuration/#scrape_config). +For the sake of reducing the burden on developers, it was elected to leave this +endpoint open by handling it in middleware, bypassing authentication +requirements. This was a conscious decision since Conjur already contains other +unprotected endpoints for debugging/status info. None of the metrics data +captured will contain sensitive values or data. + +It was also taken into account that production deployments of Conjur are less +likely to leverage this feature, but if they do, there will almost certainly be +a load balancer which can easily be configured to require basic auth on the +`/metrics` endpoint if required. + +## Integrations + +As mentioned, Prometheus allows for a variety of integrations for monitoring +captured metrics. [Grafana](https://prometheus.io/docs/visualization/grafana/) +provides a popular lightweight option for creating custom dashboards and +visualizing your data based on queries against Prometheus' data store. + +[AWS +Cloudwatch](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/ContainerInsights-Prometheus.html) +also offers a powerful option for aggregating metrics stored in Prometheus and +integrating them into its Container Insights platform in AWS +[ECS](https://aws-otel.github.io/docs/getting-started/container-insights/ecs-prometheus) +or +[EKS](https://aws-otel.github.io/docs/getting-started/container-insights/eks-prometheus) +environments. + +Similar options exist for other popular Kubernetes and cloud-monitoring +platforms, such as [Microsoft's Azure +Monitor](https://learn.microsoft.com/en-us/azure/azure-monitor/containers/container-insights-prometheus-integration) +and [Google's Cloud +Monitoring](https://cloud.google.com/stackdriver/docs/managed-prometheus). + +## Performance + +Benchmarks were taken with and without the Conjur telemetry feature enabled. It +was found that having telemetry enabled had only a negligible impact +(sub-millisecond) on system performance for handling most requests. + +By far the most expensive action is policy loading, which triggers an update to +HTTP request metrics as well as resource, role, and authenticator count metrics. +In this case, there was a 2-4% increase in processing time due to the metric +updates having to wait for a DB write to complete before being able to retrieve +the updated metric values. + +The full set of benchmarks can be reviewed +[here.](https://gist.github.com/gl-johnson/4b7fdb70a3b671f634731fe07615cedd) diff --git a/UPGRADING.md b/UPGRADING.md index 2a01625170..061e7fe796 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -19,29 +19,29 @@ that was used when you originally deployed your Conjur server. 3. Pull the new Conjur image version: ``` - docker-compose pull conjur + docker compose pull conjur ``` 4. Stop the Conjur container: ``` - docker-compose stop conjur + docker compose stop conjur ``` 5. Bring up the Conjur service using the new image version without changing linked services: ``` - docker-compose up -d --no-deps conjur + docker compose up -d --no-deps conjur ``` 6. View Docker containers and verify all are healthy, up and running: ``` - docker-compose ps -a + docker compose ps -a ``` It may also be useful to check if Conjur started successfully, which can be done by running ``` - $ docker-compose exec conjur conjurctl wait + $ docker compose exec conjur conjurctl wait Waiting for Conjur to be ready... ... Conjur is ready! @@ -56,7 +56,7 @@ environment variable first, you will be able to complete the steps without an visible/explicit error message, but the logs of the new Conjur container will show an error like: ``` -$ docker-compose logs conjur_server +$ docker compose logs conjur_server rake aborted! No CONJUR_DATA_KEY ... @@ -66,7 +66,7 @@ To fix this, set the `CONJUR_DATA_KEY` environment variable and run through the [process](#standard-process) again. This time when you check the logs of the Conjur server container you should see the service starting as expected: ``` -$ docker-compose logs conjur_server +$ docker compose logs conjur_server ... => Booting Puma => Rails 5.2.4.3 application starting in production diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 2b53930016..d31217f4f1 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -2,6 +2,7 @@ class ApplicationController < ActionController::API include Authenticates + include PrintBacktrace include ::ActionView::Layouts class Unauthorized < RuntimeError @@ -22,6 +23,9 @@ class BadGateway < RuntimeError class BadRequest < RuntimeError end + class BadRequestWithBody < RuntimeError + end + class InternalServerError < RuntimeError end @@ -43,11 +47,14 @@ class RecordExists < Exceptions::RecordExists class UnprocessableEntity < RuntimeError end - rescue_from Exceptions::RecordNotFound, with: :record_not_found + rescue_from Exceptions::RecordNotFound, with: :render_record_not_found rescue_from Errors::Conjur::MissingSecretValue, with: :render_secret_not_found + rescue_from Errors::Conjur::DuplicateVariable, with: :render_bad_request_with_message rescue_from Exceptions::RecordExists, with: :record_exists rescue_from Exceptions::Forbidden, with: :forbidden + rescue_from Exceptions::MethodNotAllowed, with: :method_not_allowed rescue_from BadRequest, with: :bad_request + rescue_from BadRequestWithBody, with: :render_bad_request_with_message rescue_from Unauthorized, with: :unauthorized rescue_from InternalServerError, with: :internal_server_error rescue_from ServiceUnavailable, with: :service_unavailable @@ -61,12 +68,19 @@ class UnprocessableEntity < RuntimeError rescue_from Exceptions::InvalidPolicyObject, with: :policy_invalid rescue_from ArgumentError, with: :argument_error rescue_from ActionController::ParameterMissing, with: :argument_error + rescue_from Errors::Conjur::ParameterMissing, with: :render_bad_request_with_message + rescue_from Errors::Conjur::ParameterValueInvalid, with: :unprocessable_entity + rescue_from Errors::Conjur::ParameterTypeInvalid, with: :unprocessable_entity + rescue_from Errors::Conjur::NumOfParametersInvalid, with: :render_bad_request_with_message rescue_from UnprocessableEntity, with: :unprocessable_entity rescue_from Errors::Conjur::BadSecretEncoding, with: :bad_secret_encoding rescue_from Errors::Authentication::RoleNotApplicableForKeyRotation, with: :method_not_allowed rescue_from Errors::Authorization::AccessToResourceIsForbiddenForRole, with: :forbidden - rescue_from Errors::Conjur::RequestedResourceNotFound, with: :resource_not_found + rescue_from Errors::Conjur::RequestedResourceNotFound, with: :render_resource_not_found rescue_from Errors::Authorization::InsufficientResourcePrivileges, with: :forbidden + rescue_from Errors::Group::DuplicateMember, with: :render_duplicate_with_message + rescue_from Errors::Group::ResourceNotMember, with: :render_resource_not_found + rescue_from Errors::Conjur::APIHeaderMissing, with: :render_bad_request_with_message around_action :run_with_transaction @@ -83,13 +97,8 @@ def run_with_transaction(&block) Sequel::Model.db.transaction(&block) end - def resource_not_found e - logger.debug("#{e}\n#{e.backtrace.join("\n")}") - render_resource_not_not_found(e) - end - - def render_resource_not_not_found e - logger.debug("#{e}\n#{e.backtrace.join("\n")}") + def render_resource_not_found e + logger.warn(e.to_s) render(json: { error: { code: "not_found", @@ -98,13 +107,8 @@ def render_resource_not_not_found e }, status: :not_found) end - def record_not_found e - logger.debug("#{e}\n#{e.backtrace.join("\n")}") - render_record_not_found(e) - end - def no_matching_row e - logger.debug("#{e}\n#{e.backtrace.join("\n")}") + logger.warn(e.to_s) target = e.dataset.model.table_name.to_s.underscore rescue nil render(json: { error: { @@ -116,32 +120,20 @@ def no_matching_row e end def foreign_key_constraint_violation e - logger.debug("#{e}\n#{e.backtrace.join("\n")}") - - # check if this is a violation of role_memberships_member_id_fkey - # or role_memberships_role_id_fkey - # sample exceptions: - # PG::ForeignKeyViolation: ERROR: insert or update on table "role_memberships" violates foreign key constraint "role_memberships_member_id_fkey" - # DETAIL: Key (member_id)=(cucumber:group:security-admin) is not present in table "roles". - # or - # PG::ForeignKeyViolation: ERROR: insert or update on table "role_memberships" violates foreign key constraint "role_memberships_role_id_fkey" - # DETAIL: Key (role_id)=(cucumber:group:developers) is not present in table "roles". - if e.message.index(/role_memberships_member_id_fkey/) || - e.message.index(/role_memberships_role_id_fkey/) - - key_string = '' - e.message.split(" ").map do |text| - if text["(member_id)"] || text["(role_id)"] - key_string = text - break - end - end - - # the member ID is inside the second set of parentheses of the key_string - key_index = key_string.index(/\(/, 1) + 1 - key = key_string[key_index, key_string.length - key_index - 1] - - exc = Exceptions::RecordNotFound.new(key, message: "Role #{key} does not exist") + logger.warn(e.to_s) + + # Check if a foreign key constraint violation is specifically a missing record, and handle it accordingly + # + # Here's a sample exception: + # + if e.is_a?(Sequel::ForeignKeyConstraintViolation) && + e.cause.is_a?(PG::ForeignKeyViolation) && + (e.cause.result.error_field(PG::PG_DIAG_MESSAGE_DETAIL) =~ /Key \(([^)]+)\)=\(([^)]+)\) is not present in table "([^"]+)"/ rescue false) + violating_key = $2 + + exc = Exceptions::RecordNotFound.new(violating_key) render_record_not_found(exc) else # if this isn't a case we're handling yet, let the exception proceed @@ -150,7 +142,7 @@ def foreign_key_constraint_violation e end def validation_failed e - logger.debug("#{e}\n#{e.backtrace.join("\n")}") + log_error(e) message = e.errors.map do |field, messages| messages.map do |message| [field, message].join(' ') @@ -177,8 +169,7 @@ def validation_failed e end def policy_invalid e - logger.debug("#{e}\n#{e.backtrace.join("\n")}") - + logger.warn(e.to_s) error = { code: "policy_invalid", message: e.message } if e.instance_of?(Conjur::PolicyParser::Invalid) @@ -194,8 +185,7 @@ def policy_invalid e end def argument_error e - logger.debug("#{e}\n#{e.backtrace.join("\n")}") - + logger.warn(e.to_s) render(json: { error: { code: error_code_of_exception_class(e.class), @@ -205,7 +195,7 @@ def argument_error e end def record_exists e - logger.debug("#{e}\n#{e.backtrace.join("\n")}") + logger.warn(e.to_s) render(json: { error: { code: "conflict", @@ -221,12 +211,12 @@ def record_exists e end def forbidden e - logger.debug("#{e}\n#{e.backtrace.join("\n")}") + logger.warn(e.to_s) head(:forbidden) end def method_not_allowed e - logger.debug("#{e}\n#{e.backtrace.join("\n")}") + logger.warn(e.to_s) render(json: { error: { code: :method_not_allowed, @@ -236,7 +226,7 @@ def method_not_allowed e end def conflict e - logger.debug("#{e}\n#{e.backtrace.join("\n")}") + log_error(e) render(json: { error: { code: :conflict, @@ -246,12 +236,12 @@ def conflict e end def bad_request e - logger.debug("#{e}\n#{e.backtrace.join("\n")}") + logger.warn(e.to_s) head(:bad_request) end def unprocessable_entity e - logger.debug("#{e}\n#{e.backtrace.join("\n")}") + logger.warn(e.to_s) render(json: { error: { code: :unprocessable_entity, @@ -260,8 +250,28 @@ def unprocessable_entity e }, status: :unprocessable_entity) end + def render_bad_request_with_message e + logger.warn(e.to_s) + render(json: { + error: { + code: :bad_request, + message: e.message + } + }, status: :bad_request) + end + + def render_duplicate_with_message e + logger.warn(e.to_s) + render(json: { + error: { + code: :conflict, + message: e.message + } + }, status: :conflict) + end + def bad_secret_encoding e - logger.debug("#{e}\n#{e.backtrace.join("\n")}") + log_error(e) render(json: { error: { code: :not_acceptable, @@ -271,7 +281,7 @@ def bad_secret_encoding e end def unauthorized e - logger.debug("#{e}\n#{e.backtrace.join("\n")}") + logger.warn(e.to_s) if e.return_message_in_response render(json: { error: { @@ -285,27 +295,27 @@ def unauthorized e end def internal_server_error e - logger.debug("#{e}\n#{e.backtrace.join("\n")}") + log_error(e) head(:internal_server_error) end def service_unavailable e - logger.debug("#{e}\n#{e.backtrace.join("\n")}") + log_error(e) head(:service_unavailable) end def gateway_timeout e - logger.debug("#{e}\n#{e.backtrace.join("\n")}") + log_error(e) head(:gateway_timeout) end def bad_gateway e - logger.debug("#{e}\n#{e.backtrace.join("\n")}") + log_error(e) head(:bad_gateway) end def not_implemented e - logger.debug("#{e}\n#{e.backtrace.join("\n")}") + log_error(e) render(json: { error: { code: "not_implemented", @@ -320,7 +330,7 @@ def account end def render_secret_not_found e - logger.debug("#{e}\n#{e.backtrace.join("\n")}") + logger.debug{e.to_s} render(json: { error: { code: "not_found", @@ -330,6 +340,7 @@ def render_secret_not_found e end def render_record_not_found e + logger.debug{e.to_s} render(json: { error: { code: "not_found", @@ -347,4 +358,9 @@ def render_record_not_found e def error_code_of_exception_class cls cls.to_s.underscore.split('/')[-1] end + + def log_error e + logger.error(e.to_s) + log_backtrace(e) unless e.backtrace.nil? + end end diff --git a/app/controllers/authenticate_controller.rb b/app/controllers/authenticate_controller.rb index 12e8b73c2b..ec0acf3e5f 100644 --- a/app/controllers/authenticate_controller.rb +++ b/app/controllers/authenticate_controller.rb @@ -4,12 +4,57 @@ class AuthenticateController < ApplicationController include BasicAuthenticator include AuthorizeResource + def authenticate_via_get + handler = Authentication::CommandHandlers::Authentication.new( + authenticator_type: params[:authenticator] + ) + + # Allow an authenticator to define the params it's expecting + response = handler.call( + parameters: params.permit(handler.params_allowed).to_h.symbolize_keys, + request_ip: request.ip + ).bind do |auth_token| + return render_authn_token(auth_token) + end + + error_response(response) + rescue => e + log_backtrace(e) + raise e + end + + def authenticate_via_post + handler = Authentication::CommandHandlers::Authentication.new( + authenticator_type: params[:authenticator] + ) + + response = handler.call( + parameters: params.permit(handler.params_allowed).to_h.symbolize_keys, + request_body: request.body.read, + request_ip: request.ip + ).bind do |auth_token| + return render_authn_token(auth_token) + end + error_response(response) + rescue => e + log_backtrace(e) + raise e + end + + def error_response(response) + logger.info(LogMessages::Authentication::AuthenticationError.new(response.exception)) + logger.info("Exception: #{response.exception.class.name}: #{response.exception.message}") + [*response.exception.backtrace].each { |line| logger.info(line) } + + head response.status + end + def oidc_authenticate_code_redirect # TODO: need a mechanism for an authenticator strategy to define the required # params. This will likely need to be done via the Handler. params.permit! - auth_token = Authentication::Handler::AuthenticationHandler.new( + auth_token = Authentication::CommandHandlers::Authentication.new( authenticator_type: params[:authenticator] ).call( parameters: params.to_hash.symbolize_keys, @@ -240,7 +285,7 @@ def k8s_inject_client_cert def render_authn_token(authn_token) content_type = :json if encoded_response? - logger.debug(LogMessages::Authentication::EncodedJWTResponse.new) + logger.debug{LogMessages::Authentication::EncodedJWTResponse.new} content_type = :plain authn_token = ::Base64.strict_encode64(authn_token.to_json) response.set_header("Content-Encoding", "base64") @@ -289,7 +334,7 @@ def handle_login_error(err) def handle_authentication_error(err) authentication_error = LogMessages::Authentication::AuthenticationError.new(err.inspect) - logger.info(authentication_error) + logger.warn(authentication_error) log_backtrace(err) case err @@ -323,19 +368,8 @@ def handle_authentication_error(err) end end - def log_backtrace(err) - err.backtrace.each do |line| - # We want to print a minimal stack trace in INFO level so that it is easier - # to understand the issue. To do this, we filter the trace output to only - # Conjur application code, and not code from the Gem dependencies. - # We still want to print the full stack trace (including the Gem dependencies - # code) so we print it in DEBUG level. - line.include?(ENV['GEM_HOME']) ? logger.debug(line) : logger.info(line) - end - end - def status_failure_response(error) - logger.debug("Status check failed with error: #{error.inspect}") + logger.debug{"Status check failed with error: #{error.inspect}"} payload = { status: "error", diff --git a/app/controllers/concerns/account_validator.rb b/app/controllers/concerns/account_validator.rb new file mode 100644 index 0000000000..3cf580517b --- /dev/null +++ b/app/controllers/concerns/account_validator.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module AccountValidator + extend ActiveSupport::Concern + def validate_account(account) + if %w[conjur cucumber rspec].exclude?(account) + logger.error( + Errors::Authorization::EndpointNotVisibleToRole.new( + "Account is: #{account}. Should be one of the following: [conjur cucumber rspec]" + ) + ) + raise ApplicationController::Forbidden + end + end +end diff --git a/app/controllers/concerns/body_parser.rb b/app/controllers/concerns/body_parser.rb index 98b4fd4e77..74313c01a6 100644 --- a/app/controllers/concerns/body_parser.rb +++ b/app/controllers/concerns/body_parser.rb @@ -21,7 +21,7 @@ def body_params when 'application/json' body = request.body.read begin - JSON.parse(body) + @body_payload = JSON.parse(body) rescue JSON::JSONError raise ApplicationController::BadRequest, "Unable to parse request json body: #{body}" end @@ -31,6 +31,10 @@ def body_params ).permit! end + def body_payload + @body_payload + end + def params super.merge(body_params) end diff --git a/app/controllers/concerns/cryptography.rb b/app/controllers/concerns/cryptography.rb new file mode 100644 index 0000000000..0a4ef6581d --- /dev/null +++ b/app/controllers/concerns/cryptography.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +# Cryptography is used to define HMAC or hash-based message authentication code +# this is used to hash the api key +module Cryptography + extend ActiveSupport::Concern + module_function + def hmac_api_key(pass, salt) + iter = 20 + key_len = 32 + OpenSSL::KDF.pbkdf2_hmac(pass, salt: salt, iterations: iter, length: key_len, hash: "sha256") + end +end + + diff --git a/app/controllers/concerns/current_user.rb b/app/controllers/concerns/current_user.rb index 6e12b1e34e..ec2d7d0b9f 100644 --- a/app/controllers/concerns/current_user.rb +++ b/app/controllers/concerns/current_user.rb @@ -1,12 +1,14 @@ # frozen_string_literal: true +require_relative '../../domain/secrets/cache/redis_handler' module CurrentUser extend ActiveSupport::Concern - + included do include TokenUser + include Secrets::RedisHandler end - + def current_user? begin current_user @@ -14,14 +16,23 @@ def current_user? nil end end - + def current_user @current_user ||= find_current_user end - + private - + def find_current_user - Role[token_user.roleid] || raise(ApplicationController::Forbidden) + user_in_cache = get_redis_user(token_user.roleid) + if user_in_cache.nil? + current_user = Role[token_user.roleid] + create_redis_user(token_user.roleid, current_user.to_hash!) if current_user + else + current_user = Role.new + current_user.from_hash!(user_in_cache) + end + current_user || raise(ApplicationController::Forbidden) end + end \ No newline at end of file diff --git a/app/controllers/concerns/edge_validator.rb b/app/controllers/concerns/edge_validator.rb new file mode 100644 index 0000000000..1866e26c4a --- /dev/null +++ b/app/controllers/concerns/edge_validator.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module EdgeValidator + extend ActiveSupport::Concern + + include AccountValidator + + def verify_edge_host(options) + msg = "" + raise_excep = false + + validate_account(options[:account]) + + if current_user.kind != 'host' + raise_excep = true + msg = "User kind is: #{current_user.kind}. Should be: 'host'" + elsif current_user.role_id.exclude?("host:edge/edge") + raise_excep = true + msg = "Role is: #{current_user.role_id}. Should include: 'host:edge/edge'" + else + role = Role[options[:account] + ':group:edge/edge-hosts'] + unless role&.ancestor_of?(current_user) + raise_excep = true + msg = "Current user is: #{current_user}. should be member of #{role}" + end + end + + if raise_excep + logger.error( + Errors::Authorization::EndpointNotVisibleToRole.new( + msg + ) + ) + raise ApplicationController::Forbidden + end + end + + def validate_scope(limit, offset) + if offset || limit + # 'limit' must be an integer greater than 0 and less than 2000 if given + if limit && (!numeric?(limit) || limit.to_i <= 0 || limit.to_i > 2000) + raise ArgumentError, "'limit' contains an invalid value. 'limit' must be a positive integer and less than 2000" + end + # 'offset' must be an integer greater than or equal to 0 if given + if offset && (!numeric?(offset) || offset.to_i.negative?) + raise ArgumentError, "'offset' contains an invalid value. 'offset' must be an integer greater than or equal to 0." + end + end + end + + private + def numeric? val + val == val.to_i.to_s + end + +end diff --git a/app/controllers/concerns/edge_yamls.rb b/app/controllers/concerns/edge_yamls.rb new file mode 100644 index 0000000000..ec17f8ea95 --- /dev/null +++ b/app/controllers/concerns/edge_yamls.rb @@ -0,0 +1,8 @@ +module EdgeYamls + extend ActiveSupport::Concern + def input_post_yaml(json_body) + { + "edge_identifier" => json_body + } + end +end diff --git a/app/controllers/concerns/extract_edge_resources.rb b/app/controllers/concerns/extract_edge_resources.rb new file mode 100644 index 0000000000..8915162901 --- /dev/null +++ b/app/controllers/concerns/extract_edge_resources.rb @@ -0,0 +1,10 @@ +module ExtractEdgeResources + extend ActiveSupport::Concern + + def extract_max_edge_value(account) + id = account + ":variable:edge/edge-configuration/max-edge-allowed" + secret = Resource[resource_id: id]&.secret + raise Errors::Edge::MaxEdgeAllowedNotFound.new(id: id) unless secret + secret.value + end +end diff --git a/app/controllers/concerns/find_edge_policy_resource.rb b/app/controllers/concerns/find_edge_policy_resource.rb new file mode 100644 index 0000000000..0739e5a889 --- /dev/null +++ b/app/controllers/concerns/find_edge_policy_resource.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module FindEdgePolicyResource + extend ActiveSupport::Concern + + def resource_id + [ params[:account], "policy", "edge" ].join(":") + end + + + protected + + def resource + raise Exceptions::RecordNotFound, resource_id unless resource_visible? + + resource! + end + + def resource_exists? + Resource[resource_id] ? true : false + end + + def resource_visible? + is_group_ancestor_of_role(current_user.id, "#{account}:group:Conjur_Cloud_Admins") + end + + private + + def resource! + @resource ||= Resource[resource_id] + end +end diff --git a/app/controllers/concerns/find_issuer_resource.rb b/app/controllers/concerns/find_issuer_resource.rb new file mode 100644 index 0000000000..4ea8de4fe1 --- /dev/null +++ b/app/controllers/concerns/find_issuer_resource.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true +module FindIssuerResource + include FindResource + include AccountValidator + extend ActiveSupport::Concern + + def resource_id + if request.request_method == "GET" && request.filtered_parameters[:action] == "get" + [ account, "policy", "conjur/issuers/#{params[:identifier]}" ].join(":") + else + [ account, "policy", "conjur/issuers" ].join(":") + end + end + + def find_or_create_root_policy + validate_account(account) + Loader::Types.find_or_create_root_policy(account) + end + + def account + @account ||= params[:account] + end + +end \ No newline at end of file diff --git a/app/controllers/concerns/find_policy_resource.rb b/app/controllers/concerns/find_policy_resource.rb new file mode 100644 index 0000000000..df274ff801 --- /dev/null +++ b/app/controllers/concerns/find_policy_resource.rb @@ -0,0 +1,42 @@ +require './app/domain/util/static_account' +# frozen_string_literal: true +module FindPolicyResource + extend ActiveSupport::Concern + + def resource_id(location) + [ account, "policy", location ].join(":") + end + + def find_or_create_root_policy + Loader::Types.find_or_create_root_policy(account) + end + + def account + @account ||= params[:account] + @account ||= StaticAccount.account + end + + + protected + + def resource(location) + raise Exceptions::RecordNotFound, resource_id(location) unless resource_visible?(location) + + resource!(location) + end + + def resource_exists?(location) + Resource[resource_id(location)] ? true : false + end + + def resource_visible?(location) + @resource_visible = resource!(location) && @resource.visible_to?(current_user) + end + + private + + def resource!(location) + @resource = Resource[resource_id(location)] + end + +end \ No newline at end of file diff --git a/app/controllers/concerns/follow_fetch_pcloud_secrets.rb b/app/controllers/concerns/follow_fetch_pcloud_secrets.rb new file mode 100644 index 0000000000..f9a28abb4e --- /dev/null +++ b/app/controllers/concerns/follow_fetch_pcloud_secrets.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module FollowFetchPcloudSecrets + extend ActiveSupport::Concern + + included do + before_action :check_first_pcloud_fetch, only: [:show, :batch] + def check_first_pcloud_fetch + if relevant_call? && !first_fetch_set? + set_first_fetch + end + if first_fetch_set? + # Remove the before_action for subsequent calls + self.class.skip_before_action :check_first_pcloud_fetch, only: [:show, :batch] + end + end + + def self.set_pcloud_access(value) + @@is_pcloud_fetched = value + end + + private + def relevant_call? + if action_name == "batch" + action_variables_ids = variable_ids + else + action_variables_ids = [resource_id] + end + result = current_user.kind == "host" && + action_variables_ids.any?{|v| v&.start_with?("#{get_account}:variable:data/vault")} + result + end + + PCLOUD_ACCESS_SECRET = 'internal/telemetry/first_pcloud_fetch' + + def first_fetch_set? + if !defined?(@@is_pcloud_fetched) || @@is_pcloud_fetched.nil? + @@is_pcloud_fetched = !Resource[resource_id: get_pcloud_fetch_secret_name]&.secret.nil? + end + @@is_pcloud_fetched + end + + def set_first_fetch + if Resource[resource_id: get_pcloud_fetch_secret_name] && Secret[resource_id: get_pcloud_fetch_secret_name].nil? + ::DB::Service::SecretService.instance.secret_value_change(get_pcloud_fetch_secret_name, Time.now.to_s) + @@is_pcloud_fetched = true + end + end + + def get_account + account || StaticAccount.account + end + end + + def get_pcloud_fetch_secret_name + "#{get_account}:variable:#{PCLOUD_ACCESS_SECRET}" + end +end diff --git a/app/controllers/concerns/print_backtrace.rb b/app/controllers/concerns/print_backtrace.rb new file mode 100644 index 0000000000..d02f9204f8 --- /dev/null +++ b/app/controllers/concerns/print_backtrace.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module PrintBacktrace + extend ActiveSupport::Concern + + def log_backtrace(err) + backtrace = err.backtrace + if logger.level != :debug + backtrace = backtrace.select do |line| + # We want to print a minimal stack trace in INFO level so that it is easier + # to understand the issue. To do this, we filter the trace output to only + # Conjur application code, and not code from the Gem dependencies. + # We still want to print the full stack trace (including the Gem dependencies + # code) so we print it in DEBUG level. + !line.include?(ENV['GEM_HOME']) + end + end + logger.error(backtrace.join("\n")) + end +end diff --git a/app/controllers/concerns/synchronizer_yamls.rb b/app/controllers/concerns/synchronizer_yamls.rb new file mode 100644 index 0000000000..df702b00e5 --- /dev/null +++ b/app/controllers/concerns/synchronizer_yamls.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module SynchronizerYamls + extend ActiveSupport::Concern + def input_post_yaml(json_body) + { + "synchronizer_identifier" => json_body + } + end +end diff --git a/app/controllers/concerns/trigger_message.rb b/app/controllers/concerns/trigger_message.rb new file mode 100644 index 0000000000..8cb20ac368 --- /dev/null +++ b/app/controllers/concerns/trigger_message.rb @@ -0,0 +1,17 @@ +module TriggerMessage + extend ActiveSupport::Concern + + def trigger_message_job + if Rails.application.config.conjur_config.try(:conjur_pubsub_enabled) + Thread.new do + begin + logger.debug{"Starting MessageJob"} + MessageJob.instance.run + logger.debug{"Finished MessageJob"} + rescue Exception => e + logger.error("Failed message job: #{e.message}") + end + end + end + end +end \ No newline at end of file diff --git a/app/controllers/concerns/validators/api_validator.rb b/app/controllers/concerns/validators/api_validator.rb new file mode 100644 index 0000000000..3534321545 --- /dev/null +++ b/app/controllers/concerns/validators/api_validator.rb @@ -0,0 +1,10 @@ +module APIValidator extend ActiveSupport::Concern + def validate_header + accept_header = request.headers["Accept"] + version_match = accept_header.match(/application\/x\.secretsmgr\.v(\d+)\+json/) + version = version_match[1] if version_match + unless version=="2" + raise Errors::Conjur::APIHeaderMissing + end + end +end \ No newline at end of file diff --git a/app/controllers/concerns/validators/group_membership_validator.rb b/app/controllers/concerns/validators/group_membership_validator.rb new file mode 100644 index 0000000000..e2e40c480c --- /dev/null +++ b/app/controllers/concerns/validators/group_membership_validator.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module GroupMembershipValidator + extend ActiveSupport::Concern + + def validate_conjur_admin_group(account) + validate_account(account) + + unless is_group_ancestor_of_role(current_user.id, "#{account}:group:Conjur_Cloud_Admins") + logger.error( + Errors::Authorization::EndpointNotVisibleToRole.new( + "Current user role is: #{current_user.id}. should be member of: \"group:Conjur_Cloud_Admins\"" + ) + ) + raise ApplicationController::Forbidden + end + end + + def validate_group_members_input(params, num_parameters, group_name, member_kind) + # Validate body is valid + validate_input(params, num_parameters) + # Validate member kind is supported + verify_kind(member_kind) + # Validate group is not identity users groups + verify_group_allowed(group_name) + end + + def is_group_ancestor_of_role(role_id, group_name) + group = Role[group_name] + unless group&.ancestor_of?(role = Role[role_id]) + return false + end + return true + end + + def is_role_member_of_group(role_id, group_id, policy_id) + role_membership = ::RoleMembership[role_id: group_id, member_id: role_id, + policy_id: GroupMemberType.get_resource_id(account, "policy", policy_id)] + !role_membership.nil? + end + + private + + def validate_input(data, num_parameters) + data_fields = { + kind: String, + id: String, + branch: String, + group_name: String + } + + Util::validate_data(data, data_fields, num_parameters) + end + + def verify_group_allowed(group_name) + allowed_id = %w[Conjur_Cloud_Admins Conjur_Cloud_Users] + if allowed_id.include?(group_name) + raise Errors::Conjur::ParameterValueInvalid.new("Group Name", "Action is not allowed for groups [Conjur_Cloud_Admins, Conjur_Cloud_Users]") + end + end + + def resource_exists_validation(resource_id) + resource = Resource.find(resource_id: resource_id) + if resource.nil? + raise Exceptions::RecordNotFound.new(resource_id) + end + end + + def verify_kind(kind) + allowed_kind = %w[host user group] + unless allowed_kind.include?(kind) + raise Errors::Conjur::ParameterValueInvalid.new("Member Kind", "Allowed values are [host, user, group]") + end + end + +end diff --git a/app/controllers/concerns/validators/params_validator.rb b/app/controllers/concerns/validators/params_validator.rb new file mode 100644 index 0000000000..2299942112 --- /dev/null +++ b/app/controllers/concerns/validators/params_validator.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +module ParamsValidator + extend ActiveSupport::Concern + + ID_FIELD_MAX_ALLOWED_LENGTH = 60 + ID_FIELD_ALLOWED_CHARACTERS = /\A[a-zA-Z0-9_]+\z/ + + # Validates given params based on validator. + # params may include body json which can be recursive, hence need to traverse recursively + def validate_params(params, validator) + params.each do |key, value| + if value.is_a?(Hash) # value is an item in a nested json from body + validate_params(value, validator) + else + unless validator.call(key, value) + raise ApplicationController::UnprocessableEntity, "Value provided for parameter #{key} is invalid" + end + end + end + end + + def validate_data_fields(fields_validations) + fields_validations.each do |field_name, field_validation| + field_validation[:validators].each do |validator| + validator.call(field_name, field_validation[:field_info]) + end + end + end + + def numeric_validator + @numeric_validator ||= ->(k, v){ v.is_a?(Numeric)} + end + + def string_length_validator(min_length=0, max_length=20) + @string_length_validator ||= ->(k, v){(v.is_a?(String) && v.length <= max_length && v.length >= min_length)} + end + + def validate_name(name) + unless name.match?(ID_FIELD_ALLOWED_CHARACTERS) + raise ApplicationController::BadRequestWithBody, "Invalid 'name' parameter. Only the following characters are supported: A-Z, a-z, 0-9 and _" + end + + if name.length > ID_FIELD_MAX_ALLOWED_LENGTH + raise ApplicationController::BadRequestWithBody, "'name' parameter length exceeded. Limit the length to #{ID_FIELD_MAX_ALLOWED_LENGTH} characters" + end + end + + def validate_id(param_name, data) + validate_string(param_name, data[:value], /\A[a-zA-Z0-9._-]+\z/, 60) + end + + def validate_path(param_name, data) + validate_string(param_name, data[:value], /\A[a-zA-Z0-9_\/-]+\z/, 500) + end + + def validate_resource_id(param_name, data) + validate_string(param_name, data[:value], /\A[a-zA-Z0-9@._\/-]+\z/, 500) + end + + def validate_mime_type(param_name, data) + validate_string(param_name, data[:value], /^[a-zA-Z0-9!#$&^_-]+\/[a-zA-Z0-9!\#$&^_-]+(?:\+[a-zA-Z0-9!\#$&^_-]+)?$/, 100) + end + + def validate_role_arn(param_name, data) + validate_string(param_name, data[:value], %r{arn:aws:iam::\d{12}:role/[\w+=,.@-]+}, 1000) + end + + def validate_region(param_name, data) + validate_string(param_name, data[:value], /^[a-z]+(?:-[a-z]+)?-\d+$/, 32) + end + + def validate_annotation_value(param_name, data) + validate_string(param_name, data[:value], /^[^<>']+$/, 120) + end + + def validate_positive_integer(param_name, data) + if !data[:value].nil? && data[:value] < 0 + raise ApplicationController::UnprocessableEntity, "#{param_name} must be positive number" + end + end + + def validate_field_required(param_name, data) + # The field exists in data + if data[:value].nil? || (data[:value].is_a?(String) and data[:value].empty?) + raise Errors::Conjur::ParameterMissing.new(param_name) + end + end + + def validate_field_type(param_name, data) + unless data[:value].nil? + # The field is of correct type + unless data[:value].is_a?(data[:type]) + raise Errors::Conjur::ParameterTypeInvalid.new(param_name, data[:type].to_s) + end + end + end + + def validate_resource_kind(resource_kind, resource_id, allowed_kind) + unless allowed_kind.include?(resource_kind) + raise Errors::Conjur::ParameterValueInvalid.new("Resource #{resource_id} kind", "Allowed values are #{allowed_kind}") + end + end + + def validate_privilege(resource_id, privileges, allowed_privilege) + privileges.each do |privilege| + unless allowed_privilege.include?(privilege) + raise Errors::Conjur::ParameterValueInvalid.new("Resource #{resource_id} privileges", "Allowed values are #{allowed_privilege}") + end + end + end + + private + def validate_string(param_name, data, regex_pattern, max_size) + unless data.nil? + unless data.match?(regex_pattern) + raise ApplicationController::UnprocessableEntity, "Invalid '#{param_name}' parameter." + end + + if data.length > max_size + raise ApplicationController::UnprocessableEntity, "'#{param_name}' parameter length exceeded. Limit the length to #{max_size} characters" + end + end + end + +end diff --git a/app/controllers/edge/api/edge_configuration_controller.rb b/app/controllers/edge/api/edge_configuration_controller.rb new file mode 100644 index 0000000000..cb418b288f --- /dev/null +++ b/app/controllers/edge/api/edge_configuration_controller.rb @@ -0,0 +1,77 @@ +class EdgeConfigurationController < RestController + include AccountValidator + include BodyParser + include Cryptography + include EdgeValidator + include ExtractEdgeResources + include GroupMembershipValidator + + def max_edges_allowed + logger.debug{LogMessages::Endpoints::EndpointRequested.new("edge/max-allowed")} + allowed_params = %i[account] + options = params.permit(*allowed_params).to_h.symbolize_keys + validate_conjur_admin_group(options[:account]) + secret_value = extract_max_edge_value(options[:account]) + begin + render(plain: secret_value, content_type: "text/plain") + logger.debug{LogMessages::Endpoints::EndpointFinishedSuccessfully.new("edge/max-allowed")} + rescue => e + logger.error(LogMessages::Conjur::GeneralError.new(e.message)) + raise e + end + end + def log_message_get_role(id) + "Validating role '#{id}'" + end + + def get_role + logger.debug{LogMessages::Endpoints::EndpointRequested.new(log_message_get_role(current_user.id))} + allowed_params = %i[account] + options = params.permit(*allowed_params).to_h.symbolize_keys + begin + render json: { + is_Conjur_Cloud_Admins: is_group_ancestor_of_role(current_user.id, "#{options[:account]}:group:Conjur_Cloud_Admins"), + is_Conjur_Cloud_Users: is_group_ancestor_of_role(current_user.id, "#{options[:account]}:group:Conjur_Cloud_Users"), + is_edge_hosts: is_group_ancestor_of_role(current_user.id, "#{options[:account]}:group:edge/edge-hosts") } + logger.debug{LogMessages::Endpoints::EndpointFinishedSuccessfully.new(log_message_get_role(current_user.id))} + rescue => e + logger.error(LogMessages::Conjur::GeneralError.new(e.message)) + raise e + end + end + + EDGE_NOT_FOUND = "Edge not found" + def get_edge_info + logger.debug{LogMessages::Endpoints::EndpointRequested.new("GET /agents/#{params[:account]}/#{params[:identifier]}/info")} + allowed_params = %i[account identifier] + options = params.permit(*allowed_params).to_h.symbolize_keys + verify_edge_host(options) + if params[:identifier] != Edge.hostname_to_id(current_user.role_id) + logger.error( + Errors::Authorization::EndpointNotVisibleToRole.new( + "Requested identifier #{params[:identifier]} is not allowed for user #{current_user.role_id}" + ) + ) + raise ApplicationController::Forbidden + end + begin + edge = Edge.where(id: params[:identifier]).first + if edge + render(json: { + id: edge.id, + name: edge.name + }) + else + raise Exceptions::RecordNotFound.new(params[:identifier], message: EDGE_NOT_FOUND) + end + logger.debug{LogMessages::Endpoints::EndpointFinishedSuccessfully.new("GET /agents/#{params[:account]}/#{params[:identifier]}/info")} + rescue Exceptions::RecordNotFound => e + @error_message = e.message + raise Exceptions::RecordNotFound.new(params[:identifier], message: EDGE_NOT_FOUND) + rescue => e + logger.error(LogMessages::Conjur::GeneralError.new(e.message)) + raise e + end + end +end + diff --git a/app/controllers/edge/api/edge_creation_controller.rb b/app/controllers/edge/api/edge_creation_controller.rb new file mode 100644 index 0000000000..1ef84d7cbf --- /dev/null +++ b/app/controllers/edge/api/edge_creation_controller.rb @@ -0,0 +1,99 @@ + +require_relative '../../wrappers/policy_wrapper' + +class EdgeCreationController < RestController + include AccountValidator + include BodyParser + include ExtractEdgeResources + include EdgeValidator + include EdgeYamls + include FindEdgePolicyResource + include GroupMembershipValidator + include PolicyWrapper + include ParamsValidator + + def generate_install_token + allowed_params = %i[account edge_name] + options = params.permit(*allowed_params).to_h.symbolize_keys + logger.debug{LogMessages::Endpoints::EndpointRequested.new("edge/edge-creds/#{options[:account]}/#{options[:edge_name]}")} + audit_params = { edge_name: options[:edge_name], user: current_user.role_id, client_ip: request.ip } + begin + validate_conjur_admin_group(options[:account]) + + edge = Edge[name: options[:edge_name]] || (raise RecordNotFound.new(options[:edge_name], message: "Edge #{options[:edge_name]} not found")) + installer_token = edge.get_installer_token(options[:account], request) + + edge_host_name = Role.username_from_roleid(edge.get_edge_host_name(options[:account])) + + response.set_header("Content-Encoding", "base64") + render(plain: Base64.strict_encode64(edge_host_name + ":" + installer_token)) + logger.debug{LogMessages::Endpoints::EndpointFinishedSuccessfully.new("edge/edge-creds/#{options[:account]}/#{options[:edge_name]}")} + rescue => e + audit_params[:error_message] = e.message + logger.error{LogMessages::Conjur::GeneralError.new(e.message)} + raise e + ensure + Audit.logger.log(Audit::Event::CredsGeneration.new(**audit_params)) + end + end + + #this endpoint loads a policy with the edge host values + adds the edge name to Edge table + def create_edge + logger.debug{LogMessages::Endpoints::EndpointRequested.new("create edge #{params[:edge_name]}")} + allowed_params = %i[account edge_name edge_id] + url_params = params.permit(*allowed_params) + validate_conjur_admin_group(url_params[:account]) + edge_name = url_params[:edge_name] + validate_name(edge_name) + edge_uuid = url_params[:edge_id] + validate_uuid_format(edge_uuid) + params[:identifier] = "edge" + + begin + max_edges = extract_max_edge_value(url_params[:account]) + Edge.new_edge(max_edges: max_edges, name: edge_name, id: edge_uuid) + edge = Edge[name: edge_name] + add_edge_host_policy(edge[:id]) + + head :created + logger.debug{LogMessages::Endpoints::EndpointFinishedSuccessfully.new("create edge #{url_params[:edge_name]}")} + rescue => e + @error_message = e.message + logger.error(LogMessages::Conjur::GeneralError.new(e.message)) + raise e + ensure + created_audit(edge_name) + end + end + + private + + def add_edge_host_policy(host_id) + input = input_post_yaml(host_id) + submit_policy(Loader::CreatePolicy, PolicyTemplates::CreateEdge.new(), input, resource) + end + + def created_audit(edge_name = "not-found") + audit_params = { edge_name: edge_name, user: current_user.role_id, client_ip: request.ip} + audit_params[:error_message] = @error_message if @error_message + Audit.logger.log(Audit::Event::EdgeCreation.new( + **audit_params + )) + end + + def validate_name(name) + validate_params({"edge_name" => name}, ->(k,v){ + !v.nil? && !v.empty? && + v.match?(/^[a-zA-Z0-9_]+$/) && string_length_validator(0, 60).call(k, v) + }) + end + + def validate_uuid_format(uuid) + uuid_regex = /^[0-9a-z]{8}-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{12}$/ + validate_params({"edge_id" => uuid}, ->(k,v){ + v.nil? || v.empty? || + v.to_s.downcase.match?(uuid_regex) + }) + end + +end diff --git a/app/controllers/edge/api/edge_deletion_controller.rb b/app/controllers/edge/api/edge_deletion_controller.rb new file mode 100644 index 0000000000..1f91f2b1df --- /dev/null +++ b/app/controllers/edge/api/edge_deletion_controller.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +class EdgeDeletionController < RestController + include AccountValidator + include EdgeYamls + include GroupMembershipValidator + include PolicyWrapper + include FindEdgePolicyResource + + EDGE_NOT_FOUND = "Edge not found" + + def delete_edge + logger.debug{LogMessages::Endpoints::EndpointRequested.new("DELETE edge/#{params[:account]}/#{params[:identifier]}")} + validate_conjur_admin_group(params[:account]) + edge_record = get_edge_from_db(params[:identifier]) + unless edge_record + raise Exceptions::RecordNotFound.new(params[:identifier], message: EDGE_NOT_FOUND) + end + delete_edge_host_policy(edge_record.id) + edge_record.destroy + head(204) + logger.debug{LogMessages::Endpoints::EndpointFinishedSuccessfully.new("DELETE edge/#{params[:account]}/#{params[:identifier]}")} + rescue Exceptions::RecordNotFound => e + @error_message = e.message + raise Exceptions::RecordNotFound.new(params[:identifier], message: EDGE_NOT_FOUND) + rescue => e + @error_message = e.message + logger.error(LogMessages::Conjur::GeneralError.new(e.message)) + raise e + ensure + deleted_audit(params[:identifier]) + end + + private + + def get_edge_from_db(edge_name) + Edge.where(name: edge_name).first + end + def delete_edge_host_policy(host_id) + input = input_post_yaml(host_id) + submit_policy(Loader::ModifyPolicy, PolicyTemplates::DeleteEdge.new(), input, resource, true) + end + + def deleted_audit(edge_name = "not-found") + audit_params = { edge_name: edge_name, user: current_user.role_id, client_ip: request.ip} + audit_params[:error_message] = @error_message if @error_message + Audit.logger.log(Audit::Event::EdgeDeletion.new( + **audit_params + )) + end +end diff --git a/app/controllers/edge/api/edge_visibility_controller.rb b/app/controllers/edge/api/edge_visibility_controller.rb new file mode 100644 index 0000000000..46b8dcd37b --- /dev/null +++ b/app/controllers/edge/api/edge_visibility_controller.rb @@ -0,0 +1,51 @@ +class EdgeVisibilityController < RestController + include AccountValidator + include BodyParser + include Cryptography + include EdgeValidator + include ExtractEdgeResources + include GroupMembershipValidator + + def all_edges + logger.debug{LogMessages::Endpoints::EndpointRequested.new("edge")} + allowed_params = %i[account] + options = params.permit(*allowed_params).to_h.symbolize_keys + validate_conjur_admin_group(options[:account]) + begin + render(json: Edge.order(:name).all.map{|edge| + {name: edge.name, ip: edge.ip, last_sync: edge.last_sync.to_i, + version:edge.version, installation_date: edge.installation_date.to_i, platform: edge.platform}}) + logger.debug{LogMessages::Endpoints::EndpointFinishedSuccessfully.new("edge")} + rescue => e + logger.error(LogMessages::Conjur::GeneralError.new(e.message)) + raise e + end + end + + def get_edge_name + logger.debug{LogMessages::Endpoints::EndpointRequested.new("GET edge/name/#{params[:account]}/#{params[:identifier]}")} + allowed_params = %i[account identifier] + options = params.permit(*allowed_params).to_h.symbolize_keys + verify_edge_host(options) + if params[:identifier] != Edge.hostname_to_id(current_user.role_id) + logger.error( + Errors::Authorization::EndpointNotVisibleToRole.new( + "Requested identifier #{params[:identifier]} is not allowed for user #{current_user.role_id}" + ) + ) + raise ApplicationController::Forbidden + end + begin + edge = Edge.where(id: params[:identifier]).first + if edge.nil? + render(json: {error: "Edge with id #{params[:identifier]} not found"}, status: 404) + else + render(json: {name: edge.name}) + end + logger.debug{LogMessages::Endpoints::EndpointFinishedSuccessfully.new("GET edge/name/#{params[:account]}/#{params[:identifier]}")} + rescue => e + logger.error(LogMessages::Conjur::GeneralError.new(e.message)) + raise e + end + end +end \ No newline at end of file diff --git a/app/controllers/edge/internal/edge_authenticators_controller.rb b/app/controllers/edge/internal/edge_authenticators_controller.rb new file mode 100644 index 0000000000..da18a8bc32 --- /dev/null +++ b/app/controllers/edge/internal/edge_authenticators_controller.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require_relative '../../../domain/edge_logic/authenticators/authenticators_manager' + +class EdgeAuthenticatorsController < RestController + include AccountValidator + include BodyParser + include Cryptography + include EdgeValidator + include AuthenticatorsManager + def all_authenticators + logger.debug{LogMessages::Endpoints::EndpointRequested.new("all-authenticators replication for edge '#{Edge.get_name_by_hostname(current_user.role_id)}'")} + allowed_params = %i[account kind limit offset count] + options = params.permit(*allowed_params).to_h.symbolize_keys + + begin + verify_edge_host(options) + verify_kind(options[:kind]) + kinds = options[:kind].split(',') + if params[:count] == 'true' + generate_count_response(kinds) + else + generate_auth_response(kinds, options) + end + rescue ApplicationController::Forbidden + raise + rescue ArgumentError => e + raise ApplicationController::UnprocessableEntity, e.message + rescue => e + raise ApplicationController::InternalServerError, e.message + end + end + + private + + def generate_auth_response(kinds, options) + offset = options[:offset] + limit = options[:limit] + validate_scope(limit, offset) + verify_header(request) + response.set_header("Content-Encoding", "base64") + parsed_data = get_authenticators_parsed_data(kinds, offset, limit) + render(json: parsed_data) + logger.debug{LogMessages::Endpoints::EndpointFinishedSuccessfully.new( + "all-authenticators replication for edge '#{Edge.get_name_by_hostname(current_user.role_id)}'")} + end + + def generate_count_response(kinds) + scope = get_authenticators_data(kinds) + count_authenticators = {} + scope.each do |authenticator_kind, authenticators_list| + count_authenticators[authenticator_kind] = authenticators_list.size + end + results = { count: count_authenticators } + render(json: results) + logger.debug{LogMessages::Endpoints::EndpointFinishedSuccessfullyWithCount.new( + "all-authenticators replication for edge '#{Edge.get_name_by_hostname(current_user.role_id)}'", params[:count])} + end + + def verify_kind(kinds_param) + allowed_kind = ['authn-jwt'] + kinds = kinds_param.to_s.split(',') + # the kind param cant be empty, and the values have to be from the allowed_kind list + unless kinds.present? && kinds.all? { |value| allowed_kind.include?(value) } + raise ArgumentError , "authenticator kind parameter is not valid" + end + end + def verify_header(request) + accepts_base64 = String(request.headers['Accept-Encoding']).casecmp?('base64') + unless accepts_base64 + raise ApplicationController::InternalServerError , "the header request must contain base64 accept-encoding" + end + end + +end diff --git a/app/controllers/edge/internal/edge_handler_controller.rb b/app/controllers/edge/internal/edge_handler_controller.rb new file mode 100644 index 0000000000..f13398a8dc --- /dev/null +++ b/app/controllers/edge/internal/edge_handler_controller.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class EdgeHandlerController < RestController + include AccountValidator + include BodyParser + include Cryptography + include EdgeValidator + include ExtractEdgeResources + include GroupMembershipValidator + + def log_message(role_id) + "edge telemetry for edge '#{Edge.get_name_by_hostname(role_id)}'" + end + def report_edge_data + logger.debug{LogMessages::Endpoints::EndpointRequested.new(log_message(current_user.role_id))} + + allowed_params = %i[account data_type] + url_params = params.permit(*allowed_params) + verify_edge_host(url_params) + data_handlers = {'install' => EdgeLogic::DataHandlers::InstallHandler , 'ongoing' => EdgeLogic::DataHandlers::OngoingHandler} + handler = data_handlers[url_params[:data_type]] + raise BadRequest unless handler + + handler.new(logger).call(params, current_user.role_id, request.ip) + + logger.debug{LogMessages::Endpoints::EndpointFinishedSuccessfully.new(log_message(current_user.role_id))} + end + +end diff --git a/app/controllers/edge/internal/edge_hosts_controller.rb b/app/controllers/edge/internal/edge_hosts_controller.rb new file mode 100644 index 0000000000..50dedbd85b --- /dev/null +++ b/app/controllers/edge/internal/edge_hosts_controller.rb @@ -0,0 +1,60 @@ +require_relative '../../../domain/edge_logic/replication_handler' + +class EdgeHostsController < RestController + include AccountValidator + include BodyParser + include Cryptography + include EdgeValidator + include ExtractEdgeResources + include GroupMembershipValidator + include ReplicationHandler + def all_hosts + logger.debug{LogMessages::Endpoints::EndpointRequested.new( + "all_hosts replication for edge '#{Edge.get_name_by_hostname(current_user.role_id)}'")} + + allowed_params = %i[account limit offset] + options = params.permit(*allowed_params) + .slice(*allowed_params).to_h.symbolize_keys + + begin + verify_edge_host(options) + scope = Role.where(:role_id.like(options[:account] + ":host:data/%")) + if params[:count] == 'true' + sumItems = scope.count('*'.lit) + else + offset = options[:offset] + limit = options[:limit] + validate_scope(limit, offset) + scope = scope.order(:role_id).limit( + (limit || 1000).to_i, + (offset || 0).to_i + ) + end + rescue ApplicationController::Forbidden + raise + rescue ArgumentError => e + raise ApplicationController::UnprocessableEntity, e.message + end + + begin + if params[:count] == 'true' + results = { count: sumItems } + render(json: results) + logger.debug{LogMessages::Endpoints::EndpointFinishedSuccessfully.new( + "all_hosts:count replication for edge '#{Edge.get_name_by_hostname(current_user.role_id)}'")} + else + sync_time = Time.now.utc.iso8601(3) + results = replicate_hosts(scope) + render(json: { "hosts": results, "timestamp": sync_time}) + logger.debug{LogMessages::Endpoints::EndpointFinishedSuccessfullyWithLimitAndOffset.new( + "all_hosts replication for edge '#{Edge.get_name_by_hostname(current_user.role_id)}'", + limit, + offset + )} + end + rescue => e + raise ApplicationController::InternalServerError, e.message + end + end + +end \ No newline at end of file diff --git a/app/controllers/edge/internal/edge_secrets_controller.rb b/app/controllers/edge/internal/edge_secrets_controller.rb new file mode 100644 index 0000000000..9128fdf561 --- /dev/null +++ b/app/controllers/edge/internal/edge_secrets_controller.rb @@ -0,0 +1,152 @@ +require_relative '../../../domain/edge_logic/replication_handler' + +class EdgeSecretsController < RestController + include AccountValidator + include BodyParser + include Cryptography + include EdgeValidator + include ExtractEdgeResources + include GroupMembershipValidator + include ReplicationHandler + + def secrets + if params[:id] + specific_secret + else + all_secrets + end + end + + # Return a specific secret + def specific_secret + logger.debug{LogMessages::Endpoints::EndpointRequested.new("specific_secret replication for edge '#{Edge.get_name_by_hostname(current_user.role_id)}'")} + + allowed_params = %i[account variable_id] + options = params.permit(*allowed_params).slice(*allowed_params).to_h.symbolize_keys + + verify_edge_host(options) + + selective_enabled = ENV['SELECTIVE_REPLICATION_ENABLED'] || "false" + accepts_base64 = String(request.headers['Accept-Encoding']).casecmp?('base64') + if accepts_base64 + response.set_header("Content-Encoding", "base64") + end + + begin + results, failed = replicate_single_secret(params[:id], accepts_base64, selective_enabled) + + if failed.empty? + limit = 1 + offset = 0 + logger.debug{LogMessages::Util::FailedSerializationOfResources.new( + "single secrets replication for edge '#{Edge.get_name_by_hostname(current_user.role_id)}'", + limit, + offset, + failed.size, + failed.first + )} + end + render(json: { "secrets": results, "failed": failed }) + logger.debug{LogMessages::Endpoints::EndpointFinishedSuccessfully.new( + "specific_secret replication for edge '#{Edge.get_name_by_hostname(current_user.role_id)}'")} + rescue => e + raise ApplicationController::InternalServerError, e.message + end + end + + # Return all secrets within offset-limit frame. Default is 0-1000 + def all_secrets + logger.debug{LogMessages::Endpoints::EndpointRequested.new( + "all_secrets replication for edge '#{Edge.get_name_by_hostname(current_user.role_id)}'")} + + allowed_params = %i[account limit offset] + options = params.permit(*allowed_params) + .slice(*allowed_params).to_h.symbolize_keys + sum_items = 0 + begin + verify_edge_host(options) + # selective replication currently disabled, this code will be removed after edge will get permissions for all variables + selective_enabled = ENV['SELECTIVE_REPLICATION_ENABLED'] || "false" + + if params[:count] == 'true' + if selective_enabled == "true" + sum_items = do_count_selective(options) + else + sum_items = do_count(options) + end + + else + limit, offset = self.get_offset_limit(options) + validate_scope(limit, offset) + end + rescue ApplicationController::Forbidden + raise + rescue ArgumentError => e + raise ApplicationController::UnprocessableEntity, e.message + end + + begin + if params[:count] == 'true' + generate_count_response(sum_items) + else + generate_secrets_result(limit, offset, options,selective_enabled) + end + rescue => e + raise ApplicationController::InternalServerError, e.message + end + end + + private + def get_offset_limit(options) + offset = options[:offset] || "0" + limit = options[:limit] || "1000" + [limit, offset] + end + + def generate_secrets_result(limit, offset, options,selective_enabled) + sync_time = Time.now.utc.iso8601(3) + accepts_base64 = String(request.headers['Accept-Encoding']).casecmp?('base64') + if accepts_base64 + response.set_header("Content-Encoding", "base64") + end + results, failed = replicate_secrets(limit, offset, options, accepts_base64,selective_enabled) + + render(json: { "secrets": results, "failed": failed, "timestamp": sync_time}) + logger.debug{LogMessages::Endpoints::EndpointFinishedSuccessfullyWithLimitAndOffset.new( + "all_secrets replication for edge '#{Edge.get_name_by_hostname(current_user.role_id)}'", + limit, + offset + )} + if failed.size > 0 + logger.debug{LogMessages::Util::FailedSerializationOfResources.new( + "all_secrets replication for edge '#{Edge.get_name_by_hostname(current_user.role_id)}'", + limit, + offset, + failed.size, + failed.first + )} + end + end + + def generate_count_response(sumItems) + results = { count: sumItems } + render(json: results) + logger.debug{LogMessages::Endpoints::EndpointFinishedSuccessfully.new( + "all_secrets:count replication for edge '#{Edge.get_name_by_hostname(current_user.role_id)}'")} + end + + def do_count_selective(options) + sum_items = 0 + count_query = "SELECT count(*) from allowed_secrets_per_role('" + current_user.id + "','" + options[:account] +":variable:data/%', '10000000', '0')" + Sequel::Model.db.fetch(count_query) do |row| + sum_items = row[:count] + break + end + sum_items + end + + def do_count(options) + scope = Resource.where(:resource_id.like(options[:account] + ":variable:data/%")) + scope.count('*'.lit) + end +end diff --git a/app/controllers/edge/internal/edge_slosilo_keys_controller.rb b/app/controllers/edge/internal/edge_slosilo_keys_controller.rb new file mode 100644 index 0000000000..21b66449e8 --- /dev/null +++ b/app/controllers/edge/internal/edge_slosilo_keys_controller.rb @@ -0,0 +1,56 @@ +class EdgeSlosiloKeysController < RestController + include AccountValidator + include BodyParser + include Cryptography + include EdgeValidator + include ExtractEdgeResources + include GroupMembershipValidator + + def log_message(role_id) + "slosilo_keys replication for edge '#{Edge.get_name_by_hostname(role_id)}'" + end + def slosilo_keys + logger.debug{LogMessages::Endpoints::EndpointRequested.new(log_message(current_user.role_id))} + + allowed_params = %i[account] + options = params.permit(*allowed_params).to_h.symbolize_keys + begin + verify_edge_host(options) + rescue ApplicationController::Forbidden + raise + end + account = options[:account] + + key = Account.token_key(account, "host") + if key.nil? + raise RecordNotFound, "No Slosilo key in DB" + end + + begin + return_json = {} + key_object = [get_key_object(key)] + return_json[:slosiloKeys] = key_object + + prev_key = Account.token_key(account, "host", "previous") + prev_key_obj = prev_key.nil? ? key_object : [get_key_object(prev_key)] + return_json[:previousSlosiloKeys] = prev_key_obj + + render(json: return_json) + logger.debug{LogMessages::Endpoints::EndpointFinishedSuccessfully.new(log_message(current_user.role_id))} + rescue => e + raise ApplicationController::InternalServerError, e.message + end + end + + private + + def get_key_object(key) + private_key = key.to_der.unpack("H*")[0] + fingerprint = key.fingerprint + variable_to_return = {} + variable_to_return[:privateKey] = private_key + variable_to_return[:fingerprint] = fingerprint + variable_to_return + end + +end \ No newline at end of file diff --git a/app/controllers/feature_flag_controller.rb b/app/controllers/feature_flag_controller.rb new file mode 100644 index 0000000000..595a0e6270 --- /dev/null +++ b/app/controllers/feature_flag_controller.rb @@ -0,0 +1,20 @@ + +class FeatureFlagController < RestController + + def feature_flag + filtered_features = [] + # Controllers are created per request, so we must use a class variable to cache the result + # of the app config call. We use a mutex to ensure that only one thread is updating the cache + Mutex.new.synchronize do + @@result_cache ||= Util::TimeExpiredCache.new(60) {Aws::AppConfigDataClient.new.get_latest_configuration} + filtered_features = @@result_cache.result + end + render(json: { featureFlags: filtered_features }) + end + + def purge_result + Mutex.new.synchronize do + @@result_cache = nil + end + end +end \ No newline at end of file diff --git a/app/controllers/groups_membership_controller.rb b/app/controllers/groups_membership_controller.rb new file mode 100644 index 0000000000..4752630f4c --- /dev/null +++ b/app/controllers/groups_membership_controller.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +class GroupsMembershipController < V2RestController + include AuthorizeResource + include BodyParser + include GroupMembershipValidator + include Secrets::RedisHandler + + NUM_OF_ADD_DATA_PARAMS = 7 + NUM_OF_REMOVE_DATA_PARAMS = 6 + + def log_message_add(params) + "Add member #{params[:kind]}:#{params[:id]} to group #{params[:branch]}/#{params[:group_name]}" + end + def add_member + logger.debug{LogMessages::Endpoints::EndpointRequested.new(log_message_add(params))} + action = :update + + group, member, member_id, member_kind = input_validation(action, NUM_OF_ADD_DATA_PARAMS) + + # If membership is already granted, grant_to will return nil. + # In this case, throw error + unless (membership = group.grant_to(member)) + raise Errors::Group::DuplicateMember.new(member_id, member_kind, group[:role_id]) + end + + clean_membership_cache + + logger.debug{LogMessages::Endpoints::EndpointFinishedSuccessfully.new(log_message_add(params))} + render(json: { + kind: member_kind, + id: member_id + }, status: :created) + audit_success(membership, :add) + rescue => e + audit_failure(e, :add) + raise e + end + + def log_message_remove(params) + "Remove member #{params[:kind]}:#{params[:id]} from group #{params[:branch]}/#{params[:group_name]}" + end + + def remove_member + logger.debug{LogMessages::Endpoints::EndpointRequested.new(log_message_remove(params))} + action = :update + + group, member, member_id, member_kind = input_validation(action, NUM_OF_REMOVE_DATA_PARAMS) + + membership = ::RoleMembership[role_id: group[:role_id], member_id: member[:role_id]] + if membership + membership.destroy + clean_membership_cache + else #If the resource is not a member raise an error + raise Errors::Group::ResourceNotMember.new(member_id, member_kind, group[:role_id]) + end + + logger.debug{LogMessages::Endpoints::EndpointFinishedSuccessfully.new(log_message_remove(params))} + head(204) + audit_success(membership, :remove) + rescue => e + audit_failure(e, :remove) + raise e + end + + private + + def audit_success(membership, operation) + Audit.logger.log( + Audit::Event::Policy.new( + operation: operation, + subject: Audit::Subject::RoleMembership.new(membership.pk_hash), + user: current_user, + client_ip: request.ip + ) + ) + end + + def audit_failure(err, operation) + Audit.logger.log( + Audit::Event::Policy.new( + operation: operation, + subject: {}, # Subject is empty because no role/resource has been impacted + user: current_user, + client_ip: request.ip, + error_message: err.message + ) + ) + end + + def input_validation(action, num_of_params) + permitted_params = params.permit(:branch, :group_name, :kind, :id).to_h.symbolize_keys + branch = get_branch(permitted_params) + group_name = permitted_params[:group_name] + member_kind = permitted_params[:kind] + member_id = permitted_params[:id] + + # validate all input is correct + validate_group_members_input(params, num_of_params, group_name, member_kind) + + # Validate there is permissions for current user to run update on the branch + authorize(action, get_resource("policy", branch)) + + group_id = full_resource_id("group", "#{branch}/#{group_name}") + group = Role[group_id] + raise Exceptions::RecordNotFound, group_id unless group + + member_full_id = get_member_full_id(member_kind, member_id) + member = Role[member_full_id] + raise Exceptions::RecordNotFound, member_full_id unless member + + [group, member, member_id, member_kind] + end + + private + def get_member_full_id(member_kind, member_id) + #We support the member path to start with / and without but for full id we need it without / + if member_id.start_with?("/") + member_id = member_id[1..-1] + end + full_resource_id(member_kind, member_id) + end + + def get_branch(params) + branch = params[:branch] + if branch.nil? + branch = "root" + end + branch + end +end \ No newline at end of file diff --git a/app/controllers/health_controller.rb b/app/controllers/health_controller.rb new file mode 100644 index 0000000000..4060ce45d0 --- /dev/null +++ b/app/controllers/health_controller.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true +class HealthController < ActionController::API + + def health + if check_db_connection + head :ok + else + head :service_unavailable + end + response.headers['Cache-Control'] = 'no-cache, no-store, max-age=0, must-revalidate' + response.headers['Pragma'] = 'no-cache' + response.headers['Expires'] = '0' + end + def check_db_connection + begin + Sequel::Model.db['SELECT 1'].single_value + return true + rescue Exception => e + return false + end + end +end \ No newline at end of file diff --git a/app/controllers/info_tenant_controller.rb b/app/controllers/info_tenant_controller.rb new file mode 100644 index 0000000000..79287dbd48 --- /dev/null +++ b/app/controllers/info_tenant_controller.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class InfoTenantController < V2RestController + def get + logger.debug{LogMessages::Endpoints::EndpointRequested.new("Get info about the tenant")} + begin + synchronizer_policy = "#{account}:policy:synchronizer" + synchronizerPolicyResource = Resource.find(resource_id: synchronizer_policy) + + is_pam_self_hosted = !synchronizerPolicyResource.nil? + tenant_id = ENV["TENANT_ID"] + logger.debug{LogMessages::Endpoints::EndpointFinishedSuccessfully.new("Get info endpoint")} + response.set_header("Content-Type", "application/json") + render(json: {is_pam_self_hosted: is_pam_self_hosted, tenant_id: tenant_id }, status: :ok) + + rescue => e + logger.warn(LogMessages::Conjur::GeneralError.new(e.message)) + raise e + end + end + +end diff --git a/app/controllers/issuers_controller.rb b/app/controllers/issuers_controller.rb new file mode 100644 index 0000000000..83fbd64413 --- /dev/null +++ b/app/controllers/issuers_controller.rb @@ -0,0 +1,347 @@ +# frozen_string_literal: true + +require_relative '../controllers/wrappers/policy_wrapper' +require_relative '../controllers/wrappers/policy_audit' +require_relative '../controllers/wrappers/templates_renderer' +require_relative '../domain/issuers/issuer_types/issuer_type_factory' +class IssuersController < RestController + include AccountValidator + include AuthorizeResource + include PolicyAudit + include PolicyWrapper + include PolicyTemplates::TemplatesRenderer + include BodyParser + include FindIssuerResource + + before_action :current_user + before_action :find_or_create_root_policy + + rescue_from Sequel::UniqueConstraintViolation, with: :concurrent_load + + ISSUER_NOT_FOUND = "Issuer not found" + SENSITIVE_DATA_MASK = "*****" + + def update + logger.debug{LogMessages::Endpoints::EndpointRequested.new("PATCH issuers/#{params[:account]}/update/#{params[:identifier]}")} + + action = :update + authorize(action, resource) + + issuer = Issuer.find(issuer_id: params[:identifier]) + raise Exceptions::RecordNotFound.new(params[:identifier], message: ISSUER_NOT_FOUND) if issuer.nil? + + issuer_type = IssuerTypeFactory.new.create_issuer_type(issuer.issuer_type) + issuer_type.validate_update(body_params) + + update_issuer_ttl(params, issuer) + update_issuer_data(params, issuer) + + issuer.save + + issuer_audit_success(issuer.account, issuer.issuer_id, "update") + logger.info(LogMessages::Issuers::TelemetryIssuerLog.new("update", issuer.account, issuer.issuer_id, request.ip)) + json_response = mask_sensitive_data_in_response(issuer.as_json) + render(json: json_response, status: :ok) + rescue => e + audit_failure(e, action) + issuer_audit_failure(params[:account], params[:identifier], "update", e.message) + raise e + end + + def create + logger.debug{LogMessages::Endpoints::EndpointRequested.new("POST issuers/#{params[:account]}")} + action = :create + authorize(action, resource) + + issuer_type = IssuerTypeFactory.new.create_issuer_type(params[:type]) + issuer_type.validate(body_params) + + issuerResource = Issuer.find(issuer_id: params[:id]) + if not issuerResource.nil? + raise Exceptions::RecordExists.new("issuer", params[:id]) + end + + issuer = Issuer.new(issuer_id: params[:id], account: params[:account], + issuer_type: params[:type], + max_ttl: params[:max_ttl], data: params[:data].to_json, + modified_at: Time.now, + policy_id: "#{params[:account]}:policy:conjur/issuers/#{params[:id]}") + + raise ApplicationController::InternalServerError, "Found variables associated with the issuer id" if issuer.issuer_variables_exist? + + create_issuer_policy({ "id" => params[:id] }) + issuer.save + issuer_audit_success(issuer.account, issuer.issuer_id, "add") + logger.info(LogMessages::Issuers::TelemetryIssuerLog.new("create", issuer.account, issuer.issuer_id, request.ip)) + json_response = mask_sensitive_data_in_response(issuer.as_json) + render(json: json_response, status: :created) + + logger.debug{LogMessages::Endpoints::EndpointFinishedSuccessfully.new("POST issuers/#{params[:account]}")} + rescue Exceptions::RecordNotFound => e + logger.warn(LogMessages::Issuers::IssuerEndpointForbidden.new("create")) + audit_failure(e, action) + issuer_audit_failure(params[:account], params[:id], "add", e.message) + raise Exceptions::Forbidden, "issuers" + rescue ApplicationController::BadRequestWithBody, ApplicationController::UnprocessableEntity => e + logger.warn("Input validation error for issuer [#{params[:id]}]: #{e.message}") + audit_failure(e, action) + issuer_audit_failure(params[:account], params[:id], "add", e.message) + raise e + rescue Exceptions::RecordExists => e + logger.warn("The issuer [#{params[:id]}] already exists") + audit_failure(e, action) + issuer_audit_failure(params[:account], params[:id], "add", e.message) + raise e + rescue => e + audit_failure(e, action) + issuer_audit_failure(params[:account], params[:id], "add", e.message) + logger.error(LogMessages::Conjur::GeneralError.new(e.message)) + head :internal_server_error + end + + def delete + logger.debug{LogMessages::Endpoints::EndpointRequested.new("DELETE issuers/#{params[:account]}/#{params[:identifier]}")} + action = :update + authorize(action, resource) + + issuer = get_issuer_from_db(params[:account], params[:identifier]) + if issuer + # Deleting the issuer policy causes a cascade delete of the issuers object as well + delete_issuer_policy({ "id" => params[:identifier] }) + # Unless requested otherwise, we need to keep the issuer related variables + unless params[:keep_secrets] == "true" + begin + deleted_variables = issuer.delete_issuer_variables + logger.info(LogMessages::Issuers::TelemetryIssuerLog.new("delete variables of", issuer.account, issuer.issuer_id, request.ip)) + issuer_variables_audit_delete(issuer.account, issuer.issuer_id, deleted_variables) + rescue => e + error_message = "Failed deleting Issuer #{params[:identifier]} variables. #{e.message}" + raise ApplicationController::InternalServerError, error_message + end + end + logger.info(LogMessages::Issuers::TelemetryIssuerLog.new("delete", issuer.account, issuer.issuer_id, request.ip)) + issuer_audit_success(issuer.account, issuer.issuer_id, "remove") + head :no_content + else + raise Exceptions::RecordNotFound.new(params[:identifier], message: ISSUER_NOT_FOUND) + end + + logger.debug{LogMessages::Endpoints::EndpointFinishedSuccessfully.new("DELETE issuers/#{params[:account]}/#{params[:identifier]}")} + rescue Exceptions::RecordNotFound => e + logger.warn(LogMessages::Issuers::IssuerPolicyNotFound.new(resource_id)) + audit_failure(e, action) + issuer_audit_failure(params[:account], params[:identifier], "remove", e.message) + raise Exceptions::RecordNotFound.new(params[:identifier], message: ISSUER_NOT_FOUND) + rescue => e + audit_failure(e, action) + issuer_audit_failure(params[:account], params[:identifier], "remove", e.message) + logger.error(LogMessages::Conjur::GeneralError.new(e.message)) + raise e + end + + def get + logger.debug{LogMessages::Endpoints::EndpointRequested.new("GET issuers/#{params[:account]}/#{params[:identifier]}")} + minimum_request = is_minimum_request(params) + if minimum_request + # If there is use permissions I can see the minimum info + action = :use + else + # If I can update the issuer policy, it means I am allowed to view it as well + action = :update + end + authorize(action, resource) + + issuer = get_issuer_from_db(params[:account], params[:identifier]) + if issuer + if minimum_request + operation = "fetch minimum" + key_to_keep = "max_ttl" + stripped_issuer = { key_to_keep => issuer[key_to_keep.to_sym] } + result = stripped_issuer.as_json + else + operation = "fetch" + result = issuer.as_json + end + issuer_audit_success(issuer.account, issuer.issuer_id, operation) + logger.info(LogMessages::Issuers::TelemetryIssuerLog.new(operation, issuer.account, issuer.issuer_id, request.ip)) + json_response = mask_sensitive_data_in_response(result) + render(json: json_response, status: :ok) + else + raise Exceptions::RecordNotFound.new(params[:identifier], message: ISSUER_NOT_FOUND) + end + + logger.debug{LogMessages::Endpoints::EndpointFinishedSuccessfully.new("GET issuers/#{params[:account]}/#{params[:identifier]}")} + rescue Exceptions::RecordNotFound => e + issuer_audit_failure(params[:account], params[:identifier], "fetch", ISSUER_NOT_FOUND) + logger.warn(LogMessages::Issuers::IssuerPolicyNotFound.new(resource_id)) + logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("GET issuers/#{params[:account]}/#{params[:identifier]}")) + raise Exceptions::RecordNotFound.new(params[:identifier], message: ISSUER_NOT_FOUND) + rescue => e + issuer_audit_failure(params[:account], params[:identifier], "fetch", e.message) + logger.error(LogMessages::Conjur::GeneralError.new(e.message)) + raise e + end + + def list + logger.debug{LogMessages::Endpoints::EndpointRequested.new("GET issuers/#{params[:account]}")} + # If I can update the issuer policy, it means I am allowed to view it as well + action = :update + authorize(action, resource) + + issuers = list_issuers_from_db(params[:account]) + results = [] + issuers.each do |item| + results.push(item.as_json_for_list) + end + results = params[:sort] ? sort_by_key(results, params[:sort]) : results + issuer_audit_success(params[:account], "*", "list") + logger.info(LogMessages::Issuers::TelemetryIssuerLog.new("list", params[:account], "*", request.ip)) + json_response = mask_sensitive_data_in_response(results) + render(json: { issuers: json_response}, status: :ok) + + logger.debug{LogMessages::Endpoints::EndpointFinishedSuccessfully.new("GET issuers/#{params[:account]}")} + rescue Exceptions::RecordNotFound => e + logger.warn(LogMessages::Issuers::IssuerEndpointForbidden.new("list")) + issuer_audit_failure(params[:account], "*", "list", e.message) + raise Exceptions::Forbidden, "issuers" + rescue => e + issuer_audit_failure(params[:account], "*", "list", e.message) + logger.error(LogMessages::Conjur::GeneralError.new(e.message)) + raise e + end + + private + + def is_minimum_request(params) + if params.key?(:projection) + if params[:projection] == "minimal" + # If there is use permissions I can see the minimum info + return true + else + raise ApplicationController::UnprocessableEntity, "Value provided for projection query param is invalid" + end + end + false + end + + def mask_sensitive_data_in_response(response) + if response.is_a?(Array) + response.each do |item| + mask_data_field(item) + end + else + mask_data_field(response) + end + response + end + + def mask_data_field(response) + return unless response.key?(:data) + + response[:data]["secret_access_key"] = SENSITIVE_DATA_MASK + end + +end +# Function to sort array of hashes by a specified key in asc order +def sort_by_key(array, key) + result = array + if array.size >0 + # check the key is a valid field + unless array[0].key?(key.to_sym) + raise ApplicationController::BadRequestWithBody, "the sort key #{key} is not a valid field of the issuer object" + end + result = array.sort_by { |hash| hash[key.to_sym] } + end + result +end + +def create_issuer_policy(policy_fields) + result_yaml = renderer(PolicyTemplates::CreateIssuer.new, policy_fields) + set_raw_policy(result_yaml) + result = load_policy(Loader::CreatePolicy, false, resource) + policy = result[:policy] + audit_success(policy) +end + +def delete_issuer_policy(policy_fields) + result_yaml = renderer(PolicyTemplates::DeleteIssuer.new, policy_fields) + set_raw_policy(result_yaml) + result = load_policy(Loader::ModifyPolicy, true, resource) + policy = result[:policy] + audit_success(policy) +end + +def get_issuer_from_db(account, issuer_id) + Issuer.where(account: account, issuer_id: issuer_id).first +end + +def list_issuers_from_db(account) + Issuer.where(account: account).select(:issuer_id, :max_ttl, :issuer_type, :created_at, :modified_at).all +end + +def issuer_audit_success(account, issuer_id, operation) + subject = { account: account, issuer: issuer_id } + Audit.logger.log( + Audit::Event::Issuer.new( + user_id: current_user.role_id, + client_ip: request.ip, + subject: subject, + message_id: "issuer", + success: true, + operation: operation + ) + ) +end + +def issuer_audit_failure(account, issuer_id, operation, error_message) + subject = { account: account, issuer: issuer_id } + Audit.logger.log( + Audit::Event::Issuer.new( + user_id: current_user.role_id, + client_ip: request.ip, + subject: subject, + message_id: "issuer", + success: false, + operation: operation, + error_message: error_message + ) + ) +end + +def issuer_variables_audit_delete(account, issuer_id, deleted_variables) + deleted_variables.each do |variable_id| + subject = { account: account, issuer: issuer_id, resource_id: variable_id } + Audit.logger.log( + Audit::Event::IssuerVariable.new( + user_id: current_user.role_id, + client_ip: request.ip, + subject: subject, + message_id: "variable", + success: true, + operation: "remove" + ) + ) + end +end + +# this function updates the issuer data but +# reuires the user to call issuer.save +# This is to save DB calls +def update_issuer_data(params, issuer) + return unless params.key?(:data) + + issuer.update(data: params[:data].to_json, modified_at: Time.now) +end + +# this function updates the issuer max_ttl but +# reuires the user to call issuer.save +# This is to save DB calls +def update_issuer_ttl(params, issuer) + return unless params.key?(:max_ttl) + + if issuer.max_ttl > params[:max_ttl] + raise ApplicationController::BadRequestWithBody, "The new max_ttl must be equal or higher than the current max_ttl" + end + + issuer.update(max_ttl: params[:max_ttl], modified_at: Time.now) +end diff --git a/app/controllers/license_controller.rb b/app/controllers/license_controller.rb new file mode 100644 index 0000000000..f4055bbd32 --- /dev/null +++ b/app/controllers/license_controller.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +class LicenseController < RestController + include GroupMembershipValidator + include StaticAccount + include AccountValidator + + COMPONENT_NAME = 'Conjur Cloud' + USER_TYPE = 'Workloads' # The user type is the type of license purchased by the user + + def show + allowed_params = %i[language] + options = params.permit(*allowed_params).to_h.symbolize_keys + logger.debug{LogMessages::Endpoints::EndpointRequested.new("GET /license/conjur#{options[:language]}")} + + unless options[:language] == 'english' + raise Errors::Conjur::ParameterValueInvalid.new("language", "#{options[:language]} is not supported") + end + + validate_user_is_in_admin_group + count = count_workloads_in_use + response = construct_response(count) + logger.debug{LogMessages::Endpoints::EndpointFinishedSuccessfully.new("GET /license/conjur#{options[:language]}")} + render(json: response, status: :ok) + end + + def count_workloads_in_use + # The user purchase a license for a specific number of workloads (hosts) + begin + options = { account: StaticAccount.account, kind: 'host', exclude: 'false' } + scope = Resource.visible_to(Role[current_user.id]) + scope = scope.search(**options) + rescue ArgumentError => e + raise ApplicationController::InternalServerError, e.message + end + scope.count('*'.lit) + end + + def validate_user_is_in_admin_group + account = StaticAccount.account + validate_conjur_admin_group(account) + end + + def construct_response(workloads_in_use) + <<-RESPONSE + { + "componentName": "#{COMPONENT_NAME}", + "optionalSummary": { + "name": "#{USER_TYPE}", + "used": "#{workloads_in_use}", + }, + "licenseData": [ + { + "licenseSubCategory": "Licenses", + "licenseElements": [ + { + "name": "#{USER_TYPE}", + "used": "#{workloads_in_use}", + } + ] + } + ] + } + RESPONSE + end +end diff --git a/app/controllers/policies_controller.rb b/app/controllers/policies_controller.rb index 9f0710a266..964b34b042 100644 --- a/app/controllers/policies_controller.rb +++ b/app/controllers/policies_controller.rb @@ -3,12 +3,19 @@ class PoliciesController < RestController include FindResource include AuthorizeResource - + include TriggerMessage + before_action :current_user before_action :find_or_create_root_policy + after_action :publish_event, if: -> { response.successful? } rescue_from Sequel::UniqueConstraintViolation, with: :concurrent_load + def run_with_transaction(&block) + super(&block) + trigger_message_job + end + # Conjur policies are YAML documents, so we assume that if no content-type # is provided in the request. set_default_content_type_for_path(%r{^/policies}, 'application/x-yaml') @@ -31,7 +38,9 @@ def post def perform(policy_action) policy_action.call new_actor_roles = actor_roles(policy_action.new_roles) - create_roles(new_actor_roles) + created_roles = create_roles(new_actor_roles) + updated_roles = update_roles + created_roles.merge(updated_roles) end def find_or_create_root_policy @@ -42,6 +51,7 @@ def find_or_create_root_policy def load_policy(action, loader_class, delete_permitted) authorize(action) + loader_class.authorize(current_user, self.resource) policy = save_submitted_policy(delete_permitted: delete_permitted) loaded_policy = loader_class.from_policy(policy) @@ -60,6 +70,16 @@ def load_policy(action, loader_class, delete_permitted) def audit_success(policy) policy.policy_log.lazy.map(&:to_audit_event).each do |event| Audit.logger.log(event) + log_dynamic_variable(event) + end + end + + def log_dynamic_variable(audit_event) + if not audit_event.subject.is_a?(Audit::Subject::Resource) + return + end + if audit_event.subject.to_h[:resource].include?("variable:data/dynamic") + logger.info(LogMessages::Dynamic::DynamicVariableTelemetry.new(audit_event.operation, audit_event.subject.to_h[:resource], request.ip)) end end @@ -115,4 +135,17 @@ def create_roles(actor_roles) memo[role_id] = { id: role_id, api_key: credentials.api_key } end end + + def publish_event + Monitoring::PubSub.instance.publish('conjur.policy_loaded') + end + + # If annotation authn/api-key changed from false to true during policy load, + # the DB trigger set it to APIKEY. We need to update the api_key to a real one. + def update_roles + Credentials.where(api_key: 'APIKEY').each_with_object({}) do |credentials, memo| + role_id = credentials.role_id + memo[role_id] = { id: role_id, api_key: credentials.api_key } + end + end end diff --git a/app/controllers/policy_factories_controller.rb b/app/controllers/policy_factories_controller.rb new file mode 100644 index 0000000000..0e73566d4d --- /dev/null +++ b/app/controllers/policy_factories_controller.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require './app/domain/responses' + +class PolicyFactoriesController < RestController + include AuthorizeResource + + before_action :current_user + + def create + response = DB::Repository::PolicyFactoryRepository.new.find( + role: current_user, + **relevant_params(%i[account kind version id]) + ).bind do |factory| + Factories::CreateFromPolicyFactory.new.call( + account: params[:account], + factory_template: factory, + request_body: request.body.read, + authorization: request.headers["Authorization"] + ) + end + + render_response(response) do + render(json: response.result) + end + end + + def show + allowed_params = %i[account kind version id] + response = DB::Repository::PolicyFactoryRepository.new.find( + role: current_user, + **relevant_params(allowed_params) + ) + + render_response(response) do + presenter = Presenter::PolicyFactories::Show.new(factory: response.result) + render(json: presenter.present) + end + end + + def index + response = DB::Repository::PolicyFactoryRepository.new.find_all( + role: current_user, + account: params[:account] + ) + render_response(response) do + presenter = Presenter::PolicyFactories::Index.new(factories: response.result) + render(json: presenter.present) + end + end + + private + + def render_response(response, &block) + if response.success? + block.call + else + presenter = Presenter::PolicyFactories::Error.new(response: response) + render( + json: presenter.present, + status: response.status + ) + end + end + + def relevant_params(allowed_params) + params.permit(*allowed_params).slice(*allowed_params).to_h.symbolize_keys + end +end diff --git a/app/controllers/providers_controller.rb b/app/controllers/providers_controller.rb index 7e0be9597d..474990b31c 100644 --- a/app/controllers/providers_controller.rb +++ b/app/controllers/providers_controller.rb @@ -2,17 +2,26 @@ class ProvidersController < ApplicationController def index - namespace = Authentication::Util::NamespaceSelector.select( - authenticator_type: params[:authenticator] - ) + authenticator_klass = Authentication::AuthnOidc::V2::DataObjects::Authenticator + contract = Authentication::AuthnOidc::V2::Validations::AuthenticatorConfiguration + validator = DB::Validation.new(contract) + + authenticators = DB::Repository::AuthenticatorRepository.new.find_all( + account: params[:account], + type: params[:authenticator] + ).bind do |authenticators_data| + authenticators_data.map do |authenticator_data| + # perform validation on each record + verified_data = validator.validate(authenticator_data) + if verified_data.success? + authenticator_klass.new(**verified_data.result) + end + end.compact + end + render( - json: "#{namespace}::Views::ProviderContext".constantize.new.call( - authenticators: DB::Repository::AuthenticatorRepository.new( - data_object: "#{namespace}::DataObjects::Authenticator".constantize - ).find_all( - account: params[:account], - type: params[:authenticator] - ) + json: Authentication::AuthnOidc::V2::Views::ProviderContext.new.call( + authenticators: authenticators ) ) end diff --git a/app/controllers/resources_controller.rb b/app/controllers/resources_controller.rb index f88c87a768..09296399a2 100644 --- a/app/controllers/resources_controller.rb +++ b/app/controllers/resources_controller.rb @@ -7,7 +7,7 @@ class ResourcesController < RestController def index # Rails 5 requires parameters to be explicitly permitted before converting # to Hash. See: https://stackoverflow.com/a/46029524 - allowed_params = %i[account kind limit offset search] + allowed_params = %i[account kind limit offset search exclude] options = params.permit(*allowed_params) .slice(*allowed_params).to_h.symbolize_keys @@ -45,27 +45,41 @@ def index end result = - if params[:count] == 'true' - { count: scope.count('*'.lit) } + if params[:count] == 'true' + { count: scope.count('*'.lit) } + else + role = assumed_role(query_role) + # all_roles- find all role memberships, expanded recursively. + role_membership = role.all_roles.map(&:role_id) + # Get all resources + result = scope.select(:resources.*) + # If we want only the short result map only the resource ids + if params[:short] == 'true' + # Map only the resources id + result = result.select(:resource_id).all.map { |obj| obj.id } else - scope.select(:resources.*). + # Add information about the resources + result = result. eager(:annotations). - eager(:permissions). + eager(:permissions => lambda { |permission| permission.where(role_id: role_membership) }). eager(:secrets). - eager(:policy_versions). - all + eager(:policy_versions) + # run the query + result = result.all end + result + end audit_list_success(options) render(json: result) end def show - result = resource - audit_show_success - render(json: result) - rescue => e - audit_show_failure(e.message) - raise e + show_audit = true + #If the request came from UI ip and have query param with true value we won't send audit + if is_ip_trusted && params[:show_audit]=="false" + show_audit = false + end + show_resource(show_audit) end def permitted_roles @@ -188,4 +202,22 @@ def audit_show_failure(error_message) ) end + def show_resource(show_audit) + result = resource + if show_audit + audit_show_success + end + render(json: result) + rescue => e + if show_audit + audit_show_failure(e.message) + end + raise e + end + + def is_ip_trusted + request_ip = request.headers['HTTP_X_FORWARDED_FOR'].to_s.split(",") + request_ip.any?{|x| Rack::Request.ip_filter.call(x.strip)} + end + end diff --git a/app/controllers/roles_controller.rb b/app/controllers/roles_controller.rb index b675063a51..fe99fa2be5 100644 --- a/app/controllers/roles_controller.rb +++ b/app/controllers/roles_controller.rb @@ -90,7 +90,7 @@ def members # Returns a graph of the roles anchored on the current Role def graph - render(json: role.graph) + render(json: role.graph(current_user.role_id)) end # update_member will add or modify an existing role membership diff --git a/app/controllers/secrets/dynamic_secrets_controller.rb b/app/controllers/secrets/dynamic_secrets_controller.rb new file mode 100644 index 0000000000..7ef88a593b --- /dev/null +++ b/app/controllers/secrets/dynamic_secrets_controller.rb @@ -0,0 +1,94 @@ +class DynamicSecretsController < V2RestController + include AuthorizeResource + include BodyParser + include ParamsValidator + + def create + branch = params[:branch] + secret_name = params[:name] + log_message = "Create Dynamic Secret #{branch}/#{secret_name}" + logger.debug{LogMessages::Endpoints::EndpointRequested.new(log_message)} + + # Create the dynamic type class + dynamic_secret = Secrets::SecretTypes::DynamicSecretTypeFactory.new.create_dynamic_secret_type(params[:method]) + + #Run input validation specific to secret type + dynamic_secret.create_input_validation(params) + + # Check permissions + create_permissions = dynamic_secret.get_create_permissions(params) + create_permissions.each do |action_policy, action| + authorize(action, action_policy) + end + + # Create variable resource + created_secret = dynamic_secret.create_secret(branch, secret_name, params) + + logger.debug{LogMessages::Endpoints::EndpointFinishedSuccessfully.new(log_message)} + render(json: created_secret, status: :created) + send_success_audit('secret',"create", branch, secret_name, request.path, body_payload) + rescue => e + send_failure_audit('secret', "create", params[:branch], params[:name], request.path, body_payload, e.message) + raise e + end + + def replace + branch = params[:branch] + secret_name = params[:name] + + log_message = "Replace Dynamic Secret #{branch}/#{secret_name}" + logger.debug{LogMessages::Endpoints::EndpointRequested.new(log_message)} + + dynamic_secret = Secrets::SecretTypes::DynamicSecretTypeFactory.new.create_dynamic_secret_type(params[:method]) + + #Run input validation specific to secret type + secret = dynamic_secret.update_input_validation(params, body_params) + + # Check permissions + create_permissions = dynamic_secret.get_update_permissions(params, secret) + create_permissions.each do |action_policy, action| + authorize(action, action_policy) + end + + # Update secret + updated_secret = dynamic_secret.replace_secret(branch, secret_name, secret, params) + + logger.debug{LogMessages::Endpoints::EndpointFinishedSuccessfully.new(log_message)} + render(json: updated_secret, status: :ok) + send_success_audit('secret',"change", branch, secret_name, request.path, body_payload) + rescue => e + send_failure_audit( 'secret', "change", request.params[:branch], request.params[:name], request.path, body_payload, e.message) + raise e + end + + def show + # As the branch is part of the path we loose the / prefix + branch = request.params[:branch] + secret_name = request.params[:name] + + get_secret_log_message = "Get Dynamic Secret #{branch}/#{secret_name}" + logger.debug{LogMessages::Endpoints::EndpointRequested.new(get_secret_log_message)} + + dynamic_secret = Secrets::SecretTypes::DynamicSecretType.new + variable = dynamic_secret.get_input_validation(request.params) + + check_read_permissions(dynamic_secret, variable) + + response = dynamic_secret.as_json(branch, secret_name, variable) + logger.debug{LogMessages::Endpoints::EndpointFinishedSuccessfully.new(get_secret_log_message)} + render(json: response, status: :ok) + send_success_audit('secret',"get", branch, secret_name, request.path, nil) + rescue => e + send_failure_audit( 'secret', "get", request.params[:branch], request.params[:name], request.path, nil, e.message) + raise e + end + + private + + def check_read_permissions(secret_type_handler, variable) + read_permission = secret_type_handler.get_read_permissions(variable) + read_permission.each do |resource, action| + authorize(action, resource) + end + end +end \ No newline at end of file diff --git a/app/controllers/secrets/static_secrets_controller.rb b/app/controllers/secrets/static_secrets_controller.rb new file mode 100644 index 0000000000..e76a71c1a6 --- /dev/null +++ b/app/controllers/secrets/static_secrets_controller.rb @@ -0,0 +1,101 @@ +class StaticSecretsController < V2RestController + include AuthorizeResource + include BodyParser + include ParamsValidator + include TriggerMessage + + def run_with_transaction(&block) + super(&block) + trigger_message_job + end + + def create + branch = params[:branch] + secret_name = params[:name] + log_message = "Create Static Secret #{branch}/#{secret_name}" + logger.debug{LogMessages::Endpoints::EndpointRequested.new(log_message)} + + # Create the secret type class + static_secret = Secrets::SecretTypes::StaticSecretType.new + + #Run input validation specific to secret type + static_secret.create_input_validation(params) + + # Check permissions + create_permissions = static_secret.get_create_permissions(params) + create_permissions.each do |action_policy, action| + authorize(action, action_policy) + end + + # Create variable resource + created_secret = static_secret.create_secret(branch, secret_name, params) + + logger.debug{LogMessages::Endpoints::EndpointFinishedSuccessfully.new(log_message)} + render(json: created_secret, status: :created) + send_success_audit('secret',"create", branch, secret_name, request.path, body_payload) + rescue => e + send_failure_audit('secret', "create", params[:branch], params[:name], request.path, body_payload, e.message) + raise e + end + + def show + # As the branch is part of the path we loose the / prefix + branch = request.params[:branch] + secret_name = request.params[:name] + + get_secret_log_message = "Get Static Secret #{branch}/#{secret_name}" + logger.debug{LogMessages::Endpoints::EndpointRequested.new(get_secret_log_message)} + + secret_type_handler = Secrets::SecretTypes::StaticSecretType.new + variable = secret_type_handler.get_input_validation(request.params) + + check_read_permissions(secret_type_handler, variable) + + response = secret_type_handler.as_json(branch, secret_name, variable) + logger.debug{LogMessages::Endpoints::EndpointFinishedSuccessfully.new(get_secret_log_message)} + render(json: response, status: :ok) + send_success_audit('secret',"get", branch, secret_name, request.path, nil) + rescue => e + send_failure_audit( 'secret', "get", request.params[:branch], request.params[:name], request.path, nil, e.message) + raise e + end + + def replace + # As the branch is part of the path we loose the / prefix + branch = params[:branch] + secret_name = params[:name] + + log_message = "Replace Static Secret #{branch}/#{secret_name}" + logger.debug{LogMessages::Endpoints::EndpointRequested.new(log_message)} + + static_secret = Secrets::SecretTypes::StaticSecretType.new + + #Run input validation specific to secret type + secret = static_secret.update_input_validation(params, body_params) + + # Check permissions + create_permissions = static_secret.get_update_permissions(params, secret) + create_permissions.each do |action_policy, action| + authorize(action, action_policy) + end + + # Update secret + updated_secret = static_secret.replace_secret(branch, secret_name, secret, params) + + logger.debug{LogMessages::Endpoints::EndpointFinishedSuccessfully.new(log_message)} + render(json: updated_secret, status: :ok) + send_success_audit('secret',"change", branch, secret_name, request.path, body_payload) + rescue => e + send_failure_audit( 'secret', "change", request.params[:branch], request.params[:name], request.path, body_payload, e.message) + raise e + end + + private + + def check_read_permissions(secret_type_handler, variable) + read_permission = secret_type_handler.get_read_permissions(variable) + read_permission.each do |resource, action| + authorize(action, resource) + end + end +end \ No newline at end of file diff --git a/app/controllers/secrets_controller.rb b/app/controllers/secrets_controller.rb index 0030c5d1e9..f13d18687f 100644 --- a/app/controllers/secrets_controller.rb +++ b/app/controllers/secrets_controller.rb @@ -1,22 +1,38 @@ # frozen_string_literal: true require 'English' +require_relative '../domain/secrets/cache/redis_handler' class SecretsController < RestController include FindResource include AuthorizeResource + include FollowFetchPcloudSecrets + include Secrets::RedisHandler + include GroupMembershipValidator + include EdgeValidator + include TriggerMessage before_action :current_user + # Avoid transaction for read-only endpoints + def run_with_transaction(&block) + if params[:action].downcase.starts_with?('show') || params[:action].downcase.starts_with?('batch') + yield + else + super(&block) + trigger_message_job + end + end + def create authorize(:update) + raise Exceptions::MethodNotAllowed, "adding a static secret to a dynamic secret variable is not allowed" if dynamic_secret? + value = request.raw_post raise ArgumentError, "'value' may not be empty" if value.blank? - - Secret.create(resource_id: resource.id, value: value) - resource.enforce_secrets_version_limit + ::DB::Service::SecretService.instance.secret_value_change(resource.id, value) head(:created) ensure @@ -32,19 +48,59 @@ def create ) end + DEFAULT_MIME_TYPE = 'application/octet-stream' + + def get_resource_object(resource_id, skip_auth: false) + resource_from_cache = get_redis_resource(resource_id) + if resource_from_cache.nil? + resource_object = skip_auth ? resource! : resource + create_redis_resource(resource_id, resource_object.to_hash!) if resource_object + else + resource_object = Resource.new + resource_object.from_hash!(resource_from_cache) + unless skip_auth + raise Exceptions::RecordNotFound, resource_id unless resource_object.visible_to?(current_user) + end + end + resource_object || raise(Exceptions::RecordNotFound.new(resource_id)) + end + def show - authorize(:execute) + # Check if role is edge, if so, skip the authorization and + # get the secret even if the resource is not visible + if possibly_edge?(params, current_user) + begin + verify_edge_host(params) + resource_object = get_resource_object(resource_id, skip_auth: true) + rescue ApplicationController::Forbidden + resource_object = get_resource_object(resource_id) + authorize(:execute, resource_object) + end + else + resource_object = get_resource_object(resource_id) + authorize(:execute, resource_object) + end + version = params[:version] - unless (secret = resource.secret(version: version)) - raise Exceptions::RecordNotFound.new(\ - resource.id, message: "Requested version does not exist" - ) - end - value = secret.value + if dynamic_secret?(resource_object) + value = handle_dynamic_secret + mime_type = 'application/json' + else + # First we try to find secret in Redis. If not found, we take from DB and store the result in Redis + value, mime_type = get_redis_secret(resource_id, version) + if value.nil? + unless (secret = resource_object.secret(version: version)) + raise Exceptions::RecordNotFound.new(\ + resource_id, message: "Requested version does not exist" + ) + end + value = secret.value + mime_type = resource_object.annotation('conjur/mime_type') || DEFAULT_MIME_TYPE - mime_type = \ - resource.annotation('conjur/mime_type') || 'application/octet-stream' + create_redis_secret(resource_id, value, mime_type, version) + end + end send_data(value, type: mime_type) rescue Exceptions::RecordNotFound @@ -54,7 +110,12 @@ def show end def batch - variables = Resource.where(resource_id: variable_ids).eager(:secrets).all + #check if there is id that repeats itself + check_input_correct + + # If redis is configured then we don't want to eagerly fetch the secrets from DB + where = Resource.where(resource_id: variable_ids) + variables = redis_configured? ? where.all : where.eager(:secrets).all unless variable_ids.count == variables.count raise Exceptions::RecordNotFound, @@ -81,16 +142,20 @@ def batch end def get_secret_from_variable(variable) - secret = variable.last_secret - raise Exceptions::RecordNotFound, variable.resource_id unless secret + secret_value, _ = get_redis_secret(variable.resource_id) + if secret_value.nil? # Access DB only if not found in Redis + secret = variable.last_secret + raise Exceptions::RecordNotFound, variable.resource_id unless secret - secret_value = secret.value + secret_value = secret.value + create_redis_secret(variable.resource_id, secret_value, DEFAULT_MIME_TYPE) + end accepts_base64 = String(request.headers['Accept-Encoding']).casecmp?('base64') if accepts_base64 response.set_header("Content-Encoding", "base64") Base64.encode64(secret_value) else - secret_value + secret_value.force_encoding('UTF-8') end end @@ -120,21 +185,21 @@ def error_info } end - # NOTE: We're following REST/http semantics here by representing this as + # NOTE: We're following REST/http semantics here by representing this as # an "expirations" that you POST to you. This may seem strange given # that what we're doing is simply updating an attribute on a secret. - # But keep in mind this purely an implementation detail -- we could + # But keep in mind this purely an implementation detail -- we could # have implemented expirations in many ways. We want to expose the - # concept of an "expiration" to the user. And per standard rest, + # concept of an "expiration" to the user. And per standard rest, # we do that with a resource, "expirations." Expiring a variable # is then a matter of POSTing to create a new "expiration" resource. - # + # # It is irrelevant that the server happens to implement this request # by assigning nil to `expires_at`. # # Unfortuneatly, to be consistent with our other routes, we're abusing # query strings to represent what is in fact a new resource. Ideally, - # we'd use a slash instead, but decided that consistency trumps + # we'd use a slash instead, but decided that consistency trumps # correctness in this case. # def expire @@ -145,6 +210,22 @@ def expire private + # check if there is a chance that the user is an edge host + # doesn't verify if the user is an edge host! + def possibly_edge?(params, current_user) + allowed_params = %i[account] + options = params.permit(*allowed_params).to_h.symbolize_keys + current_user.id.start_with?("#{options[:account]}:host:edge/edge") + end + + def check_input_correct + unique_variables = variable_ids.uniq + unless variable_ids.count == unique_variables.count + duplicate_ids = variable_ids.find_all { |e| variable_ids.count(e) > 1 }.uniq + raise Errors::Conjur::DuplicateVariable, duplicate_ids.join(",") + end + end + def variable_ids return @variable_ids if @variable_ids @@ -155,4 +236,37 @@ def variable_ids @variable_ids end + + def dynamic_secret?(resource_object = resource) + resource_object.kind == "variable" && resource_object.identifier.start_with?(Issuer::DYNAMIC_VARIABLE_PREFIX) + end + + def handle_dynamic_secret + account = params[:account] + resource_annotations = resource.annotations + variable_data = {} + request_id = request.env['action_dispatch.request_id'] + + # Filter the issuer related annotations and remove the prefix + resource_annotations.each do |annotation| + next unless annotation.name.start_with?(Issuer::DYNAMIC_ANNOTATION_PREFIX) + issuer_param = annotation.name.to_s[Issuer::DYNAMIC_ANNOTATION_PREFIX.length..-1] + variable_data[issuer_param] = annotation.value + end + + issuer = Issuer.first(account: account, issuer_id: variable_data["issuer"]) + + # There shouldn't be a state where a variable belongs to an issuer that doesn't exit, but we check it to be safe + raise ApplicationController::UnprocessableEntity, "Issuer assigned to #{account}:#{params[:kind]}:#{params[:identifier]} was not found" unless issuer + + logger.info(LogMessages::Secrets::DynamicSecretRequest.new(request_id, variable_data["issuer"], issuer.issuer_type, variable_data["method"])) + + issuer_data = { + max_ttl: issuer.max_ttl, + data: JSON.parse(issuer.data) + } + + ConjurDynamicEngineClient.new(logger: logger, request_id: request_id) + .get_dynamic_secret(issuer.issuer_type, variable_data["method"], @current_user.role_id, issuer_data, variable_data) + end end diff --git a/app/controllers/status_controller.rb b/app/controllers/status_controller.rb index d9e9a2159c..58bec2a33b 100644 --- a/app/controllers/status_controller.rb +++ b/app/controllers/status_controller.rb @@ -2,7 +2,7 @@ require 'date' -class StatusController < ApplicationController +class StatusController < ActionController::API include TokenUser def index diff --git a/app/controllers/synchronizer/internal/synchronizer_creation_controller.rb b/app/controllers/synchronizer/internal/synchronizer_creation_controller.rb new file mode 100644 index 0000000000..0b53cce68c --- /dev/null +++ b/app/controllers/synchronizer/internal/synchronizer_creation_controller.rb @@ -0,0 +1,98 @@ +require_relative '../../wrappers/policy_wrapper' +require_relative '../../../domain/authentication/util/host_authentication' +require 'digest' + +class SynchronizerCreationController < V2RestController + include SynchronizerYamls + include GroupMembershipValidator + include PolicyWrapper + include AccountValidator + include HostAuthentication + + def generate_install_token + logger.debug{LogMessages::Endpoints::EndpointRequested.new("synchronizer/installer-creds endpoint started")} + validate_conjur_admin_group(account) + synchronizer_uuid = tenant_id + begin + # create the installer token + synchronizer_installer_resource_id = "#{account}:host:synchronizer/synchronizer-installer-#{synchronizer_uuid}/synchronizer-installer-host-#{synchronizer_uuid}" + synchronizerHostResource = Resource.find(resource_id: synchronizer_installer_resource_id) + raise Exceptions::RecordNotFound.new(synchronizer_installer_resource_id, message: "Synchronizer host not found") if synchronizerHostResource.nil? + installer_token = get_access_token(account, synchronizer_installer_resource_id, request) + response.set_header("Content-Encoding", "base64") + response.set_header("Content-Type", "text/plain") + + # synchronizer host id by synchronizer-component-template required + synchronizer_host_resource_id = "host/synchronizer/synchronizer-#{synchronizer_uuid}/synchronizer-host-#{synchronizer_uuid}" + + render(plain: Base64.strict_encode64(synchronizer_host_resource_id + ":" + installer_token)) + logger.debug{LogMessages::Endpoints::EndpointFinishedSuccessfully.new("synchronizer/installer-creds endpoint succeeded")} + rescue => e + @error_message = e.message + logger.error(LogMessages::Conjur::GeneralError.new(e.message)) + raise e + ensure + token_generation_audit(synchronizer_installer_resource_id) + end + end + def create_synchronizer + logger.debug{LogMessages::Endpoints::EndpointRequested.new("create synchronizer endpoint")} + validate_conjur_admin_group(account) + synchronizer_uuid = tenant_id + + begin + synchronizer_host_resource_id = "#{account}:host:synchronizer/synchronizer-#{synchronizer_uuid}/synchronizer-host-#{synchronizer_uuid}" + synchronizerHostResource = Resource.find(resource_id: synchronizer_host_resource_id) + raise Exceptions::RecordExists.new("synchronizer", synchronizer_host_resource_id) if not synchronizerHostResource.nil? + add_synchronizer_host_policy(synchronizer_uuid) + + head :created + logger.debug{LogMessages::Endpoints::EndpointFinishedSuccessfully.new("synchronizer created - #{synchronizer_uuid}")} + + rescue Exceptions::RecordExists => e + logger.warn(LogMessages::Conjur::AlreadyExists.new(synchronizer_uuid, e.message)) + raise e + rescue => e + @error_message = e.message + logger.warn(LogMessages::Conjur::GeneralError.new(e.message)) + raise e + ensure + created_audit(synchronizer_host_resource_id) + end + end + + private + + def token_generation_audit(synchronizer_id = "not-found") + audit_params = { synchronizer_id: synchronizer_id, user: current_user.role_id, client_ip: request.ip} + audit_params[:error_message] = @error_message if @error_message + Audit.logger.log(Audit::Event::TokenGeneration.new( + **audit_params + )) + end + + def created_audit(synchronizer_id = "not-found") + audit_params = { synchronizer_id: synchronizer_id, user: current_user.role_id, client_ip: request.ip} + audit_params[:error_message] = @error_message if @error_message + Audit.logger.log(Audit::Event::SynchronizerCreation.new( + **audit_params + )) + end + + def hash_string(input_string) + hashed_string = Digest::SHA256.hexdigest(input_string) + return hashed_string + end + + def tenant_id + tenant_id = Rails.application.config.conjur_config.tenant_id + return hash_string(tenant_id) + end + + def add_synchronizer_host_policy(host_id) + input = input_post_yaml(host_id) + resource = Resource["#{account}:policy:synchronizer"] + submit_policy(Loader::CreatePolicy, PolicyTemplates::CreateSynchronizer.new(), input, resource) + end + +end diff --git a/app/controllers/v2_rest_controller.rb b/app/controllers/v2_rest_controller.rb new file mode 100644 index 0000000000..bbbced9266 --- /dev/null +++ b/app/controllers/v2_rest_controller.rb @@ -0,0 +1,52 @@ +require './app/domain/resources/resources_handler' +class V2RestController < RestController + include APIValidator + include ResourcesHandler + + before_action :validate_header + before_action :current_user + after_action :update_response_header + + def update_response_header + if response.headers['Content-Type'].nil? + response.headers['Content-Type'] = 'application/x.secretsmgr.v2+json' + else + response.headers['Content-Type'] = response.headers['Content-Type'].sub('application/json', 'application/x.secretsmgr.v2+json') + end + end + + def send_success_audit(resource_type, operation, branch, resource_name, path, body) + send_audit(resource_type,operation, branch, resource_name, path, body, nil) + end + def send_failure_audit(resource_type, operation, branch, resource_name, path, body, failure_message) + send_audit(resource_type,operation, branch, resource_name, path, body, failure_message) + end + + private + def send_audit(resource_type,operation, branch, resource_name, path, body , failure_message) + full_resource_name = "#{branch}/#{resource_name}" + json_body_string = nil + #for secret: replace secret value with xxxxx for security + if body + # Parse JSON string into a Ruby hash + if body.has_key?("value") + # Update the value of the field if it exists + body["value"] = "xxxxxxx" + end + # Convert Ruby hash back to JSON string + json_body_string = JSON.generate(body) + end + Audit.logger.log( + Audit::Event::V2Resource.new( + operation: operation, + resource_type: resource_type, + resource_name: full_resource_name, + request_path: path, + request_body: json_body_string, + user: current_user.role_id, + client_ip: request.ip, + error_message: failure_message + ) + ) + end +end \ No newline at end of file diff --git a/app/controllers/workload_controller.rb b/app/controllers/workload_controller.rb new file mode 100644 index 0000000000..f82dc595bb --- /dev/null +++ b/app/controllers/workload_controller.rb @@ -0,0 +1,174 @@ +# frozen_string_literal: true +require_relative '../controllers/wrappers/policy_wrapper' +require_relative '../controllers/wrappers/policy_audit' +# +class WorkloadController < RestController + include AuthorizeResource + include BodyParser + include FindPolicyResource + include PolicyAudit + include PolicyWrapper + include ParamsValidator + + before_action :current_user + before_action :find_or_create_root_policy + + set_default_content_type_for_path(%r{^/hosts}, 'application/json') + + def post + logger.debug{LogMessages::Endpoints::EndpointRequested.new("hosts/:account/*identifier")} + action = :create + params.permit(:identifier, :account, :id, :annotations, :safes) + .to_h.symbolize_keys + authorize(action, resource(params[:identifier])) + validate_workload_id(params[:id]) + hostId = "#{params[:account]}:host:#{build_host_name_without_slash(params[:id], params[:identifier])}" + hostResource = Resource.find(resource_id: hostId) + if !hostResource.nil? + raise Exceptions::RecordExists.new("host", hostId) + end + input = build_workload_policy(params[:id], params[:annotations]) + result = submit_policy(Loader::CreatePolicy, PolicyTemplates::CreateHost.new(), input, resource(params[:identifier])) + hostPolicy = result[:policy] + grantPolicies = grantHostToSafes(params) + audit_success(hostPolicy) + grantPolicies.each do |policy| + audit_success(policy) + end + logger.debug{LogMessages::Endpoints::EndpointFinishedSuccessfully.new("hosts/:account/*identifier")} + render(json: { + created_roles: result[:created_roles] + }, status: :created) + rescue => e + audit_failure(e, action) + raise e + end + + def create + logger.debug{LogMessages::Endpoints::EndpointRequested.new("Create Host")} + action = :create + params.permit(:host_id, :account, :policy_tree, :annotations, :auth_apikey) + .to_h.symbolize_keys + policy_tree = params[:policy_tree] + host_id = params[:host_id] + # check there is permission on the policy tree + authorize(action, resource(policy_tree)) + # validate host id + validate_workload_id(host_id) + # validate policy + validate_policy(policy_tree) + # validate host doesn't exist + full_host_id = "#{params[:account]}:host:#{build_host_name_without_slash(host_id, policy_tree)}" + host_resource = Resource.find(resource_id: full_host_id) + unless host_resource.nil? + raise Exceptions::RecordExists.new("host", full_host_id) + end + # build policy json + annotations = build_annotations(params) + input = build_workload_policy(host_id, annotations) + # submit policy + result = submit_policy(Loader::CreatePolicy, PolicyTemplates::CreateHost.new(), input, resource(policy_tree)) + host_policy = result[:policy] + audit_success(host_policy) + render_response(full_host_id, host_id, policy_tree, result) + logger.debug{LogMessages::Endpoints::EndpointFinishedSuccessfully.new("Create Host")} + rescue => e + audit_failure(e, action) + if e.instance_of?(Forbidden) + #when accessing restricted resources we should always return the code 404 (not found) and never 403 (forbidden) in order to avoid resource enumeration + raise RecordNotFound.new(e.message) + else + raise e + end + end +end + +private + +def validate_workload_id(name) + validate_params({"id" => name}, ->(k,v){ + !v.nil? && !v.empty? && + v.match?(/^[a-zA-Z\/0-9_:-]+$/) && string_length_validator(3, 120).call(k, v) + }) +end + +#TODO: should move loader/types.rb. resource verify method +def validate_policy(policy) + if policy.start_with?("data/dynamic") + raise ApplicationController::UnprocessableEntity, "Value provided for policy #{policy} is invalid" + end +end + +def build_workload_policy(host_id, annotations) + { + "id" => host_id, + "annotations" => annotations + } +end + +def input_grant_safe(host_id) + { + "id" => host_id, + } +end + +def build_host_name(params) + path = [] + path << params[:identifier] unless params[:identifier] == "root" + path << params[:id] + "/" + path.join('/') +end + + +def build_host_name_without_slash(host_id, policy_tree) + path = [] + path << policy_tree unless policy_tree == "root" + path << host_id + path.join('/') +end + +def grantHostToSafes(params) + safes = params[:safes] + policies = [] + if safes.nil? + return policies + end + if !safes.is_a?(Array) + raise ApplicationController::UnprocessableEntity, "safes must be an array." + end + action = :update + host_id = build_host_name(params) + safes.each do |safe| + authorize(action, resource(safe)) + input = input_grant_safe(host_id) + result = submit_policy(Loader::CreatePolicy, PolicyTemplates::GrantHostSafe.new(), input, resource(safe)) + policies << result[:policy] + end + return policies +end + +def render_response(full_host_id, host_id, policy_tree, result) + response = { + host_id: host_id, + policy_tree: policy_tree, + } + unless params[:annotations].nil? + response["annotations"] = params[:annotations] + end + unless params[:auth_apikey].nil? + response["api_key"] = result[:created_roles][full_host_id][:api_key] + end + + render(json: response, status: :created) +end + +def build_annotations(params) + annotations = params[:annotations] + # Add api key annotation if needed + unless params[:auth_apikey].nil? + annotations["authn/api-key"] = params[:auth_apikey] + end + annotations +end + + diff --git a/app/controllers/wrappers/policy_audit.rb b/app/controllers/wrappers/policy_audit.rb new file mode 100644 index 0000000000..36ee2ea311 --- /dev/null +++ b/app/controllers/wrappers/policy_audit.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module PolicyAudit + + def audit_success(policy) + policy.policy_log.lazy.map(&:to_audit_event).each do |event| + Audit.logger.log(event) + end + end + + def audit_failure(err, operation) + Audit.logger.log( + Audit::Event::Policy.new( + operation: operation, + subject: {}, # Subject is empty because no role/resource has been impacted + user: current_user, + client_ip: request.ip, + error_message: err.message + ) + ) + end +end \ No newline at end of file diff --git a/app/controllers/wrappers/policy_wrapper.rb b/app/controllers/wrappers/policy_wrapper.rb new file mode 100644 index 0000000000..bf30077fa9 --- /dev/null +++ b/app/controllers/wrappers/policy_wrapper.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +# Policy wrapper to include stuff already existing in policies_controller.rb, +# to allow flexibility for rest endpoint who will use it differently + +require_relative 'templates_renderer' + +module PolicyWrapper + extend ActiveSupport::Concern + include PolicyTemplates::TemplatesRenderer + + def load_policy(loader_class, delete_permitted, resource) + begin + policy = save_submitted_policy(delete_permitted: delete_permitted, resource: resource) + loaded_policy = loader_class.from_policy(policy) + created_roles = perform(loaded_policy) + { created_roles: created_roles, policy: policy } + rescue Sequel::UniqueConstraintViolation => e + concurrent_load + end + end + + def submit_policy(policy_loader, policy_tamplate, input, resource, delete_permitted=false) + result_yaml = renderer(policy_tamplate, input) + set_raw_policy(result_yaml) + result = load_policy(policy_loader, delete_permitted, resource) + result + end + + def raw_policy + @raw_policy + end + + def set_raw_policy(raw_policy) + @raw_policy = raw_policy + end + + def save_submitted_policy(delete_permitted:, resource:) + policy_version = PolicyVersion.new( + role: current_user, + policy: resource, + policy_text: raw_policy, + client_ip: request.ip + ) + policy_version.delete_permitted = delete_permitted + policy_version.save + end + + def perform(policy_action) + policy_action.call + new_actor_roles = actor_roles(policy_action.new_roles) + created_roles = create_roles(new_actor_roles) + updated_roles = update_roles + created_roles.merge(updated_roles) + end + + def actor_roles(roles) + roles.select do |role| + %w[user host].member?(role.kind) + end + end + + def create_roles(actor_roles) + actor_roles.each_with_object({}) do |role, memo| + credentials = Credentials[role: role] || Credentials.create(role: role) + role_id = role.id + memo[role_id] = { id: role_id, api_key: credentials.api_key } + end + end + + def update_roles + Credentials.where(api_key: 'APIKEY').each_with_object({}) do |credentials, memo| + role_id = credentials.role_id + memo[role_id] = { id: role_id, api_key: credentials.api_key } + end + end +end + +def concurrent_load(_exception) + response.headers['Retry-After'] = retry_delay + render(json: { + error: { + code: "policy_conflict", + message: "Concurrent policy load in progress, please retry" + } + }, status: :conflict) +end + +# Delay in seconds to advise the client to wait before retrying on conflict. +# It's randomized to avoid request bunching. +def retry_delay + rand(1..8) +end \ No newline at end of file diff --git a/app/controllers/wrappers/templates_renderer.rb b/app/controllers/wrappers/templates_renderer.rb new file mode 100644 index 0000000000..5001cef6e2 --- /dev/null +++ b/app/controllers/wrappers/templates_renderer.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module PolicyTemplates + module TemplatesRenderer + def renderer(template, hash_input) + ERB.new(template.template, trim_mode: '-').result_with_hash(hash_input) + end + end +end \ No newline at end of file diff --git a/app/db/preview/roles_without_resources.rb b/app/db/preview/roles_without_resources.rb index 69e050cbd5..94310df8d7 100644 --- a/app/db/preview/roles_without_resources.rb +++ b/app/db/preview/roles_without_resources.rb @@ -2,21 +2,48 @@ module DB module Preview class RolesWithoutResources def call - roles = gather_data - if roles.count > 0 - puts("\nRoles that will be removed because the parent policy has been removed") - max_id = roles.map { |role| role.identifier }.max_by(&:length).length + item_ids = gather_data + if item_ids.count > 0 + puts("\nRoles and resources that will be removed because the parent policy has been removed") + + max_id = item_ids.map { |id| id.split(":", 3)[2] }.max_by(&:length).length printf("%-#{max_id}s %s\n", "ID", "TYPE") - roles.sort_by { |role| role.id }.each do |role| - printf("%-#{max_id}s %s\n", role.identifier, role.kind) + item_ids.sort_by { |id| id.split(":", 3)[2] }.each do |id| + printf("%-#{max_id}s %s\n", id.split(":", 3)[2], id.split(":", 3)[1]) end + else + printf("\nNo items to remove\n") end end def gather_data - Role. - exclude(role_id: Resource.all.map { |resource| resource.id }). - where(Sequel.lit('policy_id is not null')) + role_ids = Role + .where(Sequel.lit('policy_id is not null AND role_id not in (select resource_id from resources)')) + .all + .map(&:role_id) + + role_ids.each do |id| + role_ids += get_recursive_role_ids(id, 1) + end + + Resource.where(owner_id: role_ids).all.map(&:resource_id) | role_ids + end + + def get_recursive_role_ids(role_id, iterator) + if iterator > 100 + raise "Recursion limit reached" + end + + role_ids = Role + .where(Sequel.lit('EXISTS (SELECT 1 FROM resources WHERE owner_id = ? AND resource_id = role_id)', role_id)) + .all + .map(&:role_id) + + role_ids.each do |id| + role_ids += get_recursive_role_ids(id, iterator+1) + end + + role_ids end end end diff --git a/app/db/preview/slosilo.rb b/app/db/preview/slosilo.rb new file mode 100644 index 0000000000..5f7dabfcc4 --- /dev/null +++ b/app/db/preview/slosilo.rb @@ -0,0 +1,9 @@ +module DB + module Preview + class Slosilo_exists + def is_exist? + !Slosilo["authn:conjur"].nil? + end + end + end +end \ No newline at end of file diff --git a/app/db/repository/authenticator_repository.rb b/app/db/repository/authenticator_repository.rb index d546c01474..7521e0f09b 100644 --- a/app/db/repository/authenticator_repository.rb +++ b/app/db/repository/authenticator_repository.rb @@ -1,85 +1,125 @@ +# frozen_string_literal: true + module DB module Repository + # This class is responsible for loading the variables associated with a + # particular type of authenticator. Each authenticator requires a Data + # Object and Data Object Contract (for validation). Data Objects that + # fail validation are not returned. + # + # This class includes two public methods: + # - `find_all` - returns all available authenticators of a specified type + # from an account + # - `find` - returns a single authenticator based on the provided type, + # account, and service identifier. + # class AuthenticatorRepository def initialize( - data_object:, resource_repository: ::Resource, logger: Rails.logger ) @resource_repository = resource_repository - @data_object = data_object @logger = logger + + @success = ::SuccessResponse + @failure = ::FailureResponse end def find_all(type:, account:) - @resource_repository.where( - Sequel.like( - :resource_id, - "#{account}:webservice:conjur/#{type}/%" - ) - ).all.map do |webservice| + authenticators = authenticator_webservices(type: type, account: account).map do |webservice| service_id = service_id_from_resource_id(webservice.id) - - # Querying for the authenticator webservice above includes the webservices - # for the authenticator status. The filter below removes webservices that - # don't match the authenticator policy. - next unless webservice.id.split(':').last == "conjur/#{type}/#{service_id}" - - load_authenticator(account: account, service_id: service_id, type: type) + begin + load_authenticator_variables(account: account, service_id: service_id, type: type) + rescue => e + @logger.info("failed to load #{type} authenticator '#{service_id}' do to validation failure: #{e.message}") + nil + end end.compact + return @success.new(authenticators) unless authenticators.empty? + + @failure.new( + "Failed to find any authenticators for '#{type}' in account: '#{account}'" + ) end - def find(type:, account:, service_id:) - webservice = @resource_repository.where( + def find(type:, account:, service_id:) + identifier = [type, service_id].compact.join('/') + + webservice = @resource_repository.where( Sequel.like( :resource_id, - "#{account}:webservice:conjur/#{type}/#{service_id}" + "#{account}:webservice:conjur/#{identifier}" ) ).first - return unless webservice - - load_authenticator(account: account, service_id: service_id, type: type) - end + unless webservice + return @failure.new( + "Failed to find a webservice: '#{account}:webservice:conjur/#{identifier}'", + exception: Errors::Authentication::Security::WebserviceNotFound.new(identifier, account) + ) + end - def exists?(type:, account:, service_id:) - @resource_repository.with_pk("#{account}:webservice:conjur/#{type}/#{service_id}") != nil + begin + @success.new( + load_authenticator_variables( + account: account, + service_id: service_id, + type: type + ) + ) + rescue => e + @failure.new( + e.message, + exception: e, + level: :debug + ) + end end private + def authenticator_webservices(type:, account:) + @resource_repository.where( + Sequel.like( + :resource_id, + "#{account}:webservice:conjur/#{type}/%" + ) + ).all.select do |webservice| + # Querying for the authenticator webservice above includes the webservices + # for the authenticator status. The filter below removes webservices that + # don't match the authenticator policy. + # + # I can't find the escaped character that Codacy is complaining about in + # this regex expression. + # rubocop:disable Style/RedundantRegexpEscape + webservice.id.split(':').last.match?(%r{^conjur/#{type}/[\w\-_]+$}) + # rubocop:enable Style/RedundantRegexpEscape + end + end + def service_id_from_resource_id(id) full_id = id.split(':').last full_id.split('/')[2] end - def load_authenticator(type:, account:, service_id:) + def load_authenticator_variables(type:, account:, service_id:) + identifier = [type, service_id].compact.join('/') variables = @resource_repository.where( Sequel.like( :resource_id, - "#{account}:variable:conjur/#{type}/#{service_id}/%" + "#{account}:variable:conjur/#{identifier}/%" ) ).eager(:secrets).all - - args_list = {}.tap do |args| + {}.tap do |args| args[:account] = account args[:service_id] = service_id variables.each do |variable| - next unless variable.secret - - args[variable.resource_id.split('/')[-1].underscore.to_sym] = variable.secret.value + # If variable exists but does not have a secret, set the value to an empty string. + # This is used downstream for validating if a variable has been set or not, and thus, + # what error to raise. + value = variable.secret ? variable.secret.value : '' + args[variable.resource_id.split('/')[-1].underscore.to_sym] = value end end - - begin - allowed_args = %i[account service_id] + - @data_object.const_get(:REQUIRED_VARIABLES) + - @data_object.const_get(:OPTIONAL_VARIABLES) - args_list = args_list.select { |key, value| allowed_args.include?(key) && value.present? } - @data_object.new(**args_list) - rescue ArgumentError => e - @logger.debug("DB::Repository::AuthenticatorRepository.load_authenticator - exception: #{e}") - nil - end end end end diff --git a/app/db/repository/policy_factory_repository.rb b/app/db/repository/policy_factory_repository.rb new file mode 100644 index 0000000000..61bb003a22 --- /dev/null +++ b/app/db/repository/policy_factory_repository.rb @@ -0,0 +1,131 @@ +require 'base64' +require 'json' + +require './app/domain/responses' + +module DB + module Repository + module DataObjects + PolicyFactory = Struct.new( + :name, + :classification, + :version, + :policy, + :policy_branch, + :schema, + :description, + keyword_init: true + ) + end + + class PolicyFactoryRepository + def initialize( + data_object: DataObjects::PolicyFactory, + resource: ::Resource, + logger: Rails.logger + ) + @resource = resource + @data_object = data_object + @logger = logger + @success = ::SuccessResponse + @failure = ::FailureResponse + end + + def find_all(account:, role:) + factories = @resource.visible_to(role).where( + Sequel.like( + :resource_id, + "#{account}:variable:conjur/factories/%" + ) + ).all + .select { |factory| role.allowed_to?(:execute, factory) } + .group_by do |item| + # form is: 'conjur/factories/core/v1/groups' + _, _, classification, _, factory = item.resource_id.split('/') + [classification, factory].join('/') + end + .map do |_, versions| + versions.max { |a, b| factory_version(a.id) <=> factory_version(b.id) } + end + .map do |factory| + response = secret_to_data_object(factory) + response.result if response.success? + end + .compact + + if factories.empty? + return @failure.new( + 'Role does not have permission to use Factories', + status: :forbidden + ) + end + + @success.new(factories) + end + + def find(kind:, id:, account:, role:, version: nil) + factory = if version.present? + @resource["#{account}:variable:conjur/factories/#{kind}/#{version}/#{id}"] + else + @resource.where( + Sequel.like( + :resource_id, + "#{account}:variable:conjur/factories/#{kind}/%" + ) + ).all + .select { |i| i.resource_id.split('/').last == id } + .max { |a, b| factory_version(a.id) <=> factory_version(b.id) } + end + + resource_id = "#{kind}/#{version || 'v1'}/#{id}" + + if factory.blank? + @failure.new( + { resource: resource_id, message: 'Requested Policy Factory does not exist' }, + status: :not_found + ) + elsif !role.allowed_to?(:execute, factory) + @failure.new( + { resource: resource_id, message: 'Requested Policy Factory is not available' }, + status: :forbidden + ) + else + secret_to_data_object(factory) + end + end + + private + + def factory_version(factory_id) + version_match = factory_id.match(%r{/v(\d+)/[\w-]+}) + return 0 if version_match.nil? + + version_match[1].to_i + end + + def secret_to_data_object(variable) + _, _, classification, version, id = variable.resource_id.split('/') + factory = variable.secret&.value + if factory + decoded_factory = JSON.parse(Base64.decode64(factory)) + @success.new( + @data_object.new( + policy: Base64.decode64(decoded_factory['policy']), + policy_branch: decoded_factory['policy_branch'], + schema: decoded_factory['schema'], + version: version, + name: id, + classification: classification, + description: decoded_factory['schema']&.dig('description').to_s + ) + ) + else + @failure.new( + { resource: "#{classification}/#{version}/#{id}", message: 'Requested Policy Factory is not available' }, + status: :bad_request + ) + end + end + end + end +end diff --git a/app/db/service/abstract_service.rb b/app/db/service/abstract_service.rb new file mode 100644 index 0000000000..685398464d --- /dev/null +++ b/app/db/service/abstract_service.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'singleton' + +module DB + module Service + class AbstractService + include Singleton + + + def initialize + @logger = Rails.logger + end + + end + end +end diff --git a/app/db/service/permission_service.rb b/app/db/service/permission_service.rb new file mode 100644 index 0000000000..5292dfa565 --- /dev/null +++ b/app/db/service/permission_service.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module DB + module Service + + class PermissionService < AbstractService + + # Create a permission + # @param [String] resource_id + # @param [String] privilege + # @param [String] role_id + # @param [String] policy_id + # @return [Permission] + # @note user should validate the input before calling this method + def create_permission(resource_id, privilege, role_id, policy_id) + db_object = ::Permission.create( + resource_id: resource_id, + privilege: privilege, + role_id: role_id, + policy_id: policy_id + ) + + # We want to make sure that db_object is not nil. If this log appears, it means that the permission creation failed. + # This is a critical error and should be investigated. And we should throw an exception here. + if db_object.nil? + @logger.error("Permission creation failed for resource_id: #{resource_id} privilege: #{privilege} role_id: #{role_id} policy_id: {policy_id}") + return + end + ::PermissionEventInput.instance.send_event(::PermissionEventInput::CREATED, db_object) + db_object + end + + # Delete a permission + # @param [String] resource_id + # @param [String] privilege + # @param [String] role_id + # @param [String] policy_id + # @return [void] + # @note if one of the parameters are nil, this method will return without doing anything + def delete_permission(resource_id, privilege, role_id, policy_id) + # If permission is called like: ::Permission[{}] it will return the first record in the table. + # which is unexpected. So we need to check if the required fields are nil, log a warning and return + if resource_id.nil? || privilege.nil? || role_id.nil? + @logger.warn("Permission deletion failed for resource_id: #{resource_id} privilege: #{privilege} role_id: #{role_id}. One of the required fields is nil.") + return + end + + db_object = ::Permission[{ resource_id: resource_id, privilege: privilege, role_id: role_id, policy_id: policy_id }.compact] + + if db_object.nil? + @logger.warn("Permission deletion failed for resource_id: #{resource_id} privilege: #{privilege} role_id: #{role_id}") + return + end + db_object.destroy + ::PermissionEventInput.instance.send_event(::PermissionEventInput::DELETED, db_object) + end + + end + end +end diff --git a/app/db/service/resource_service.rb b/app/db/service/resource_service.rb new file mode 100644 index 0000000000..d6a14dd9a5 --- /dev/null +++ b/app/db/service/resource_service.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module DB + module Service + + class ResourceService < AbstractService + include ::Secrets::RedisHandler + + # Creates a new resource and handles specific resource types. Returns nil if creation fails. + # Currently only variable resource type is handled. + def create_resource(resource_id, owner_id, policy_id) + resource = Resource.create(resource_id: resource_id, owner_id: owner_id, policy_id: policy_id) + if resource.nil? + @logger.error("Resource creation failed for resource_id: #{resource_id} owner_id: #{owner_id} policy_id: #{policy_id}") + return nil + end + handler = self.class.resource_handlers[resource.kind] + handler.create(resource) if handler + resource + end + + # In policy controller if resource is not found, it will not raise error. + # So the function will return nil if it was not found in the DB, for other to handle it as they see fit. + def delete_resource(resource_id) + resource = ::Resource[resource_id] + if resource + resource.destroy + handler = self.class.resource_handlers[resource.kind] + handler.delete(resource) if handler + end + resource + end + + def self.resource_handlers + { + 'host' => ::DB::Service::Types::WorkloadType.instance, + 'user' => ::DB::Service::Types::UserType.instance, + 'variable' => ::DB::Service::Types::VariableType.instance + } + end + + end + end +end + diff --git a/app/db/service/resource_types/resource_type.rb b/app/db/service/resource_types/resource_type.rb new file mode 100644 index 0000000000..0bfac9ee99 --- /dev/null +++ b/app/db/service/resource_types/resource_type.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true +require 'singleton' + +module DB + module Service + module Types + class ResourceType + include Singleton + + def initialize(logger = Rails.logger) + @logger = logger + end + + def create(resource) + raise NotImplementedError + end + + def delete(resource) + raise NotImplementedError + end + end + end + end +end diff --git a/app/db/service/resource_types/user_type.rb b/app/db/service/resource_types/user_type.rb new file mode 100644 index 0000000000..17f100a551 --- /dev/null +++ b/app/db/service/resource_types/user_type.rb @@ -0,0 +1,17 @@ +module DB + module Service + module Types + class UserType < ResourceType + include ::Secrets::RedisHandler + + def create(resource) + # TODO: Implement user creation + end + + def delete(resource) + delete_redis_user(resource.id) + end + end + end + end +end \ No newline at end of file diff --git a/app/db/service/resource_types/variable_type.rb b/app/db/service/resource_types/variable_type.rb new file mode 100644 index 0000000000..1beb8bb452 --- /dev/null +++ b/app/db/service/resource_types/variable_type.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true +module DB + module Service + module Types + class VariableType < ResourceType + include ::Secrets::RedisHandler + def create(resource) + ::SecretEventInput.instance.send_event(::SecretEventInput::CREATE, resource) + end + + def delete(resource) + ## remove secret + delete_redis_secret(resource.id) + ## remove resource_id for variable in show endpoint + delete_redis_resource(resource.id) + ## write pubsub event of deletion of secret + ::SecretEventInput.instance.send_event(::SecretEventInput::DELETE, resource) + end + + end + + end + end +end \ No newline at end of file diff --git a/app/db/service/resource_types/workload_type.rb b/app/db/service/resource_types/workload_type.rb new file mode 100644 index 0000000000..0184ac32d3 --- /dev/null +++ b/app/db/service/resource_types/workload_type.rb @@ -0,0 +1,17 @@ +module DB + module Service + module Types + class WorkloadType < ResourceType + include ::Secrets::RedisHandler + + def create(resource) + # TODO Implement workload creation + end + + def delete(resource) + delete_redis_user(resource.id) + end + end + end + end +end \ No newline at end of file diff --git a/app/db/service/secret_service.rb b/app/db/service/secret_service.rb new file mode 100644 index 0000000000..10ad76f075 --- /dev/null +++ b/app/db/service/secret_service.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module DB + module Service + + class SecretService < AbstractService + include ::Secrets::RedisHandler + + def secret_value_change(secret_id, secret_value) + db_secret = Secret.create(resource_id: secret_id, value: secret_value) + # Enforce version number + db_secret.enforce_secrets_version_limit + # Redis + delete_redis_secret(db_secret.resource_id) #Delete and not update to avoid corner case where commit to DB fails + # Send event + ::SecretEventInput.instance.send_event(::SecretEventInput::CHANGE, db_secret) + end + + end + end +end + diff --git a/app/db/validation.rb b/app/db/validation.rb new file mode 100644 index 0000000000..45a2b79635 --- /dev/null +++ b/app/db/validation.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module DB + # This class provides a generic mechanism for running Dry-RB contracts + # against provided data. + class Validation + def initialize(contract, logger: Rails.logger) + @contract = contract&.new + @logger = logger + + @success = ::SuccessResponse + @failure = ::FailureResponse + end + + def validate(data) + return @success.new(data) if @contract.blank? + + result = @contract.call(**data) + if result.success? + @success.new(result.to_h) + else + # If contract fails, return the first defined exception... + error = result.errors.first + # If this is an authenticator validation and a field is missing, we want to return an alternative error. + if error.text == 'is missing' && + @contract.class.to_s.match(/\AAuthentication::Authn\w+::V2::Validations::AuthenticatorConfiguration\z/) + type = @contract.class.to_s.split('::')[1].underscore.dasherize + + return @failure.new( + "Value '#{error.path.first}' #{error.text}", + status: :unauthorized, + exception: Errors::Conjur::RequiredSecretMissing.new( + "#{data[:account]}:variable:conjur/#{type}/#{data[:service_id]}/#{error.path.first.to_s.dasherize}" + ) + ) + end + @failure.new(error, exception: error.meta[:exception]) + end + end + end + +end diff --git a/app/domain/annotaitons/annotations_handler.rb b/app/domain/annotaitons/annotations_handler.rb new file mode 100644 index 0000000000..c56db64a51 --- /dev/null +++ b/app/domain/annotaitons/annotations_handler.rb @@ -0,0 +1,39 @@ +module AnnotationsHandler + include ParamsValidator + + def validate_annotations(annotations) + annotations.each do |annotation| + data_fields = { + "annotation name": { + field_info: { + type: String, + value: annotation[:name] + }, + validators: [method(:validate_field_required), method(:validate_field_type), method(:validate_path)] + }, + "annotation value": { + field_info: { + type: String, + value: annotation[:value] + }, + validators: [method(:validate_field_required), method(:validate_field_type), method(:validate_annotation_value)] + } + } + validate_data_fields(data_fields) + end + end + + def create_annotations(resource,policy_id, annotations) + records = annotations.map { |annotation| [resource.id, annotation['name'], annotation['value'].to_s, policy_id] } + resource.annotations_dataset.import(%i[resource_id name value policy_id], records) + end + + def delete_resource_annotations(resource) + Annotation.where(resource_id: resource.id).delete + end + + def get_annotations(resource) + annotations = resource.annotations + annotations.map { |annotation| { name: annotation.name, value: annotation.value } } + end +end diff --git a/app/domain/authentication/README.md b/app/domain/authentication/README.md new file mode 100644 index 0000000000..90ac7f2c3d --- /dev/null +++ b/app/domain/authentication/README.md @@ -0,0 +1,306 @@ +# Authenticators + +Version 2 of the Conjur Authenticator Architecture marks substantial deviation +from the version 1 architecture. + +*Note: this document will not cover V1 architecture, only V2.* + +## Overview + +- [Authenticators](#authenticators) + - [Overview](#overview) + - [Authentication Workflow Paths](#authentication-workflow-paths) + + - [Components](#components) + - [Generic Components](#generic-components) + + - [Authenticator-specific Components](#authenticator-specific-components) + - [Component Interfaces](#component-interfaces) + - [Authenticator Data Object](#authenticator-data-object) + + - [Authenticator Validations](#authenticator-validations) + - [Strategy](#strategy) + - [Authenticator Repository](#authenticator-repository) + - [Authentication Handler](#authentication-handler) + + - [Developing Authenticators](#developing-authenticators) + +### Authentication Workflow Paths + +The following diagram provides an overview of the authentication steps: + +![Authenticator Workflow](./readme_assets/authenticator-workflow-paths.png) + +## Components + +### Generic Components + +These components are related to the overall authentication process. + +- `Authentication::CommandHandlers::Authentication` - handles all aspects of + the authentication cycle. This class delegates Strategy and Validation to the + specific authenticator being used. + +- `DB::Repository::AuthenticatorRepository` - interface for querying for all + or a single authenticator. This allows all relevant authenticator variable + values to be retrieved in a single request. + +- `DB::Validation` - runs a provided set of validations against a target set + of data. This interface provides a generic interface for re-mapping + `Dry::Validation::Contract` errors to Conjur specific errors. + +- `Authentication::Util::NamespaceSelector` - \[Temporary Shim] selects the +correct authenticator namespace to load authenticators components from. + +- `Authentication::Util::V2::KlassLoader` - interface which attempts to load + validation libraries. This allows authenticator validations to be optional. + +- `Authentication::Util::NetworkTransporter` - provides a generic mechanism + for making HTTP calls. This utility handles setting CA certificates on a + per-call basis. + +- `Authentication::RoleIdentifier` - data object returned by an authenticator + strategy. This object includes the target Conjur role as well as any potential + values which should be compared to the Role's annotations. + +- `Authentication::InstalledAuthenticators` - provides an allowlist of + authenticators which have been enabled. + +- `RBAC::Permission` - provides a generic interface to resolve the "does 'x' + have 'y' permissions on 'z'?" question. + +- `TokenFactory` - mints Conjur authentication tokens for a provided role. + +### Authenticator-specific Components + +These components are unique to each of our various authenticators. + +- **(Required)** `Authentication::Authn::V2::Strategy` - defines the + process through which a provided credential is validated and mapped to a + potential Conjur resource. + +- **(Required)** `Authentication::Authn::V2::DataObjects::Authenticator` - + defines the variables (and optionally default values) an authenticator + requires. These are instantiated via data from the + `DB::Repository::AuthenticatorRepository`. + +- **(Optional)** `Authentication::Authn::V2::Validations::AuthenticatorConfiguration` - +defines the specific. These validations are loaded via the `KlassLoader`. + +The following diagram provides an overview of the components used during the +authentication cycle: + +![Authentication Components](./readme_assets/authenticator-workflow-components.png) + +#### Component Interfaces + +#### Authenticator Data Object + +Authenticator Data objects are dumb objects. They are initialized with all +relevant authenticator data and should include reader methods for all +attributes. Additional helper methods can be added, but these methods should +be limited to providing alternative views of its core data. + +The following is the simplest example of an Authenticator Data Object: + +```ruby +# frozen_string_literal: true + +module Authentication + module Authn + module V2 + module DataObjects + + # This DataObject encapsulates the data required for an Authn- authenticator. + # + # DataObjects must enherit from the Base data object (which includes a variety of helper methods). + class Authenticator < Authentication::Base::DataObject + + # add additional variables (if any). + # attr_reader() + + # Authn has no variables. Add named params if this authenticator has variables. + def initialize(account:, service_id:) + super(account: account, service_id: service_id) + + # If this authenticator has a non-standard TTL: + # @token_ttl = token_ttl.present? ? token_ttl : 'PT60M' + end + end + end + end + end +end +``` + +### Authenticator Validations + +Authenticator validations provide a mechanism for validating authenticator data +prior to initializing an Authenticator Data Object. + +Example: `Authentication::AuthnOidc::V2::Validations::AuthenticatorConfiguration` + +Contracts are extended from the +[Dry RB Validation library](https://dry-rb.org/gems/dry-validation/1.8/). +They work by defining a schema: + +```ruby +module Authentication + module AuthnOidc + module V2 + module Validations + class AuthenticatorConfiguration < Authentication::Base::Validations + schema do + required(:account).value(:string) + required(:service_id).value(:string) + + optional(:provider_uri).value(:string) + optional(:ca_cert).value(:string) + ... + end + ... + end + end + end + end +end +``` + +This defines the required and optional data as well as the type. As Conjur +Variables store values as strings, they type will always be `String`. + +With a schema defined, we can check data validity with rules: + +```ruby +# Ensure claims has only one `:` in it +rule(:claim_aliases) do + bad_claim = values[:claim_aliases].to_s.split(',').find do |item| + item.count(':') != 1 + end + if (bad_claim.present?) + key.failure( + **response_from_exception( + Errors::Authentication::AuthnOidc::ClaimAliasNameInvalidCharacter.new( + bad_claim + ) + ) + ) + end +end +``` + +These rules are executed, top to bottom, additively. + +Contracts return a Success or Failure response, with either the successful +result or a list of errors. We are using some trickery to mimic the existing +Exception driven workflow for validation. By calling `failure` with desired +exception formatted with `response_from_exception`, we are defining the +desired exception that should be raised. The `AuthenticatorRepository` will +raise the first exception resulting from the contract validation. + +#### Strategy + +A strategy handles the external validation of the provided identity. It +follows the Command class pattern. + +Example: `Authentication::AuthnOidc::V2::Strategy` + +At minimum, a Strategy requires the following methods + +```ruby +# Initializer +# +# @param [Authenticator] authenticator - Authenticator Data Object that holds +# all relevant authenticator specific information. +# +# Note: additional dependencies should be defined as default parameters +def initialize(authenticator:) + @authenticator = authenticator + ... +end +``` + +```ruby +# Verifies the validity of the contents of the provided request body and/or +# request parameters +# +# @param [String] request_body - authentication request body +# @param [Hash] parameters - authentication request parameters +# +# @return something suitable for identifying a Conjur Role (usually a String +# or Hash) +def callback(request_body:, parameters:) + ... +end +``` + +Strategies should be stateless and follow the pattern of dependency injection to +allow network requests to be mocked during testing. + +#### Authenticator Repository + +Class: `DB::Repository::AuthenticatorRepository` + +The Authenticator provides a high-level interface over the Conjur Policy and +Variables associated with an Authenticator. The Authenticator Repository can +query for a single authenticator or all authenticators of a certain type. + +The repository works by identifying the relevant authenticator webservice(s) +and loading the relevant authenticator variables and values. These variables +are returned as either a Hash or an Array of Hashes. + +For a more detailed overview of how the Authenticator Repository works, +[review its implementation](https://github.com/cyberark/conjur/blob/master/app/db/repository/authenticator_repository.rb). + +#### Authentication Handler + +class `Authentication::CommandHandlers::Authentication` + +The Authentication Handler encapsulates the authentication process. It handles +the mix of generic checks (authenticator exists, is enabled, role is allowed to +authenticate from IP address, etc.) as well as calling the appropriate Strategy +and Identity Resolution implementations. + +The Authentication Handler handles the following: + +- Selects the appropriate Authenticator Data Object, Contract, Strategy, and + Identity Resolver based on the desired authenticator type (`authn-jwt`/ + `authn-oidc`/etc.) + +- Verifies that authenticator: + + - Can be used (is enabled) + - Is available (exists for desired account) + - Includes a webservice + - Is not misconfigured (using the Validations) + +- Performs verification and role resolution + +- Verifies role is allowed to authenticate from its origin (IP address or + network mask) + +- Audits success/failure + +- Generates an auth token with appropriate TTL (time to live) + +## Developing Authenticators + +The prior authenticator implementation placed an emphasis on functional +isolation. This created challenges when authenticators used common mechanisms +for validating and identifying token. For example, `authn-azure` and +`authn-gcp` both use OpenID Connect JWT tokens. This means they duplicate +functionality in `authn-oidc` and `authn-jwt`, which makes development quite +complicated. + +The new architecture emphasizes stateless strategies. This allows us to easily +create authenticators which are specific implementations of a more generic +authenticator. As an example, `authn-oidc` performs two actions: + +1. Exchange a code for n bearer token +1. Validates a JWT token from that bearer token + +To avoid code duplication, the `authn-oidc` extends the `authn-jwt` strategy. +The OIDC strategy handles the code exchange and extracts the JWT token. It then +delegates the JWT validation to the `authn-jwt` strategy. This follows the +Single Responsibility Principle, and keeps the scope of our strategies easy to +reason about. diff --git a/app/domain/authentication/authn_api_key/v2/data_objects/authenticator.rb b/app/domain/authentication/authn_api_key/v2/data_objects/authenticator.rb new file mode 100644 index 0000000000..a4048967b5 --- /dev/null +++ b/app/domain/authentication/authn_api_key/v2/data_objects/authenticator.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Authentication + module AuthnApiKey + module V2 + module DataObjects + + # This DataObject encapsulates the data required for an Authn-API-Key + # authenticator. + class Authenticator < Authentication::Base::DataObject + + # Authn API Key has no variables. + # + # Service ID is ignored as API key authentication is enabled for + # all accounts. + def initialize(account:) + super(account: account) + end + + # Override type as this class's name does not match the expected + # value of 'authn'. This is because API key is the default mechanism + # for logging into Conjur. + def type + 'authn' + end + end + end + end + end +end diff --git a/app/domain/authentication/authn_api_key/v2/strategy.rb b/app/domain/authentication/authn_api_key/v2/strategy.rb new file mode 100644 index 0000000000..b45a9406cc --- /dev/null +++ b/app/domain/authentication/authn_api_key/v2/strategy.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +# Conjur API authenticator +module Authentication + module AuthnApiKey + module V2 + class Strategy + + # This authenticator is a bit different because it validates based on the + # information stored in the Conjur database. As such, Role and Credential + # are made available. Longer term, they should probably become part of this + # authenticator. + def initialize(authenticator:, logger: Rails.logger, credentials: ::Credentials, role: ::Role) + @authenticator = authenticator + @logger = logger + @credentials = credentials + @role = role + + @success = ::SuccessResponse + @failure = ::FailureResponse + end + + # Parameter `id` is guaranteed to be present based on the + # upstream routes file. + def callback(request_body:, parameters:) + role_id = parameters[:id] + api_key = request_body + + full_role_id = if role_id.match?(%r{^host/.}) + id = role_id.split('/')[1..-1].join('/') + "#{@authenticator.account}:host:#{id}" + else + "#{@authenticator.account}:user:#{role_id}" + end + + role_identifier = Authentication::RoleIdentifier.new( + identifier: full_role_id + ) + if @role[full_role_id].nil? + return @failure.new( + role_identifier, + exception: Errors::Authentication::Security::RoleNotFound.new(role_id) + ) + end + + role_credentials = @credentials[full_role_id] + if role_credentials.nil? + return @failure.new( + role_identifier, + exception: Errors::Authentication::RoleHasNoCredentials.new(role_id) + ) + end + + return @success.new(role_identifier) if role_credentials.valid_api_key?(api_key) + + @failure.new( + role_identifier, + exception: Errors::Conjur::ApiKeyNotFound.new(role_id) + ) + end + + # TODO: need to pull this over from the authn-jwt refactor + # + # # Called by status handler. This handles checking as much of the strategy + # # integrity as possible without performing an actual authentication. + # def verify_status + # true + # end + end + end + end +end diff --git a/app/domain/authentication/authn_azure/authentication_request.rb b/app/domain/authentication/authn_azure/authentication_request.rb index 515533f22f..785beb0111 100644 --- a/app/domain/authentication/authn_azure/authentication_request.rb +++ b/app/domain/authentication/authn_azure/authentication_request.rb @@ -16,7 +16,7 @@ def valid_restriction?(restriction) xms_mirid.resource_groups when Restrictions::USER_ASSIGNED_IDENTITY xms_mirid.providers.last if user_assigned_identity? - when Restrictions::SYSTEM_ASSIGNED_IDENTITY + when Restrictions::INFRAPOOL_SYSTEM_ASSIGNED_IDENTITY @oid_token_field unless user_assigned_identity? end diff --git a/app/domain/authentication/authn_azure/authenticator.rb b/app/domain/authentication/authn_azure/authenticator.rb index f841644c30..1070ae00a8 100644 --- a/app/domain/authentication/authn_azure/authenticator.rb +++ b/app/domain/authentication/authn_azure/authenticator.rb @@ -36,7 +36,8 @@ def decoded_token claims_to_verify: { verify_iss: true, iss: provider_uri - } + }, + ca_cert: nil ), logger: @logger ) diff --git a/app/domain/authentication/authn_azure/consts.rb b/app/domain/authentication/authn_azure/consts.rb index 0d4cff0b9c..1cc93f601c 100644 --- a/app/domain/authentication/authn_azure/consts.rb +++ b/app/domain/authentication/authn_azure/consts.rb @@ -7,10 +7,10 @@ module Restrictions SUBSCRIPTION_ID = "subscription-id" RESOURCE_GROUP = "resource-group" USER_ASSIGNED_IDENTITY = "user-assigned-identity" - SYSTEM_ASSIGNED_IDENTITY = "system-assigned-identity" + INFRAPOOL_SYSTEM_ASSIGNED_IDENTITY = "system-assigned-identity" REQUIRED = [SUBSCRIPTION_ID, RESOURCE_GROUP].freeze - IDENTITY_EXCLUSIVE = [USER_ASSIGNED_IDENTITY, SYSTEM_ASSIGNED_IDENTITY].freeze + IDENTITY_EXCLUSIVE = [USER_ASSIGNED_IDENTITY, INFRAPOOL_SYSTEM_ASSIGNED_IDENTITY].freeze PERMITTED = REQUIRED + IDENTITY_EXCLUSIVE CONSTRAINTS = Constraints::MultipleConstraint.new( diff --git a/app/domain/authentication/authn_azure/decoded_token.rb b/app/domain/authentication/authn_azure/decoded_token.rb index e93d040f4d..82c0d0ef9a 100644 --- a/app/domain/authentication/authn_azure/decoded_token.rb +++ b/app/domain/authentication/authn_azure/decoded_token.rb @@ -28,12 +28,12 @@ def token_claim_value(token_claim) raise Errors::Authentication::Jwt::TokenClaimNotFoundOrEmpty, token_claim end - @logger.debug( + @logger.debug{ LogMessages::Authentication::Jwt::ExtractedClaimFromToken.new( token_claim, token_claim_value ) - ) + } token_claim_value end end diff --git a/app/domain/authentication/authn_azure/validate_status.rb b/app/domain/authentication/authn_azure/validate_status.rb index 5aa33d6e0c..f97b2e4640 100644 --- a/app/domain/authentication/authn_azure/validate_status.rb +++ b/app/domain/authentication/authn_azure/validate_status.rb @@ -34,7 +34,8 @@ def required_variable_names def validate_provider_is_responsive @discover_identity_provider.( - provider_uri: provider_uri + provider_uri: provider_uri, + ca_cert: nil ) end diff --git a/app/domain/authentication/authn_gcp/decoded_token.rb b/app/domain/authentication/authn_gcp/decoded_token.rb index c29566b051..a9e3c67865 100644 --- a/app/domain/authentication/authn_gcp/decoded_token.rb +++ b/app/domain/authentication/authn_gcp/decoded_token.rb @@ -51,7 +51,7 @@ def optional_token_claim_value(optional_token_claim) if optional_token_claim_value.nil? || optional_token_claim_value.empty? optional_token_claim_value = nil - @logger.debug(LogMessages::Authentication::Jwt::OptionalTokenClaimNotFoundOrEmpty.new(optional_token_claim)) + @logger.debug{LogMessages::Authentication::Jwt::OptionalTokenClaimNotFoundOrEmpty.new(optional_token_claim)} else log_claim_extracted_from_token(optional_token_claim, optional_token_claim_value) end @@ -65,12 +65,12 @@ def token_claim_value(token_claim) end def log_claim_extracted_from_token(token_claim, token_claim_value) - @logger.debug( + @logger.debug{ LogMessages::Authentication::Jwt::ExtractedClaimFromToken.new( token_claim, token_claim_value ) - ) + } end end end diff --git a/app/domain/authentication/authn_gcp/update_authenticator_input.rb b/app/domain/authentication/authn_gcp/update_authenticator_input.rb index 524f29b2d6..eda845b91d 100644 --- a/app/domain/authentication/authn_gcp/update_authenticator_input.rb +++ b/app/domain/authentication/authn_gcp/update_authenticator_input.rb @@ -50,7 +50,8 @@ def decoded_token iss: PROVIDER_URI, verify_iat: true, verify_expiration: true - } + }, + ca_cert: nil ), logger: @logger ) @@ -86,11 +87,11 @@ def conjur_username @conjur_username = audience_parts[2] - @logger.debug( + @logger.debug{ LogMessages::Authentication::Jwt::ExtractedUsernameFromToken.new( conjur_username ) - ) + } @conjur_username end diff --git a/app/domain/authentication/authn_gcp/validate_status.rb b/app/domain/authentication/authn_gcp/validate_status.rb index 3bf517cb93..26afd56c13 100644 --- a/app/domain/authentication/authn_gcp/validate_status.rb +++ b/app/domain/authentication/authn_gcp/validate_status.rb @@ -15,7 +15,8 @@ def call def validate_provider_is_responsive @discover_identity_provider.( - provider_uri: PROVIDER_URI + provider_uri: PROVIDER_URI, + ca_cert: nil ) end end diff --git a/app/domain/authentication/authn_iam/authenticator.rb b/app/domain/authentication/authn_iam/authenticator.rb index 5fab2f3c89..e0468c09c1 100755 --- a/app/domain/authentication/authn_iam/authenticator.rb +++ b/app/domain/authentication/authn_iam/authenticator.rb @@ -35,12 +35,12 @@ def iam_role_matches?(login:, aws_arn:, aws_account:) aws_role_name = arn_parts[5].split('/')[1] host_to_match = "#{host_prefix}/#{aws_account}/#{aws_role_name}" - @logger.debug( + @logger.debug{ LogMessages::Authentication::AuthnIam::AttemptToMatchHost.new( login, host_to_match ) - ) + } login.eql?(host_to_match) end @@ -54,13 +54,34 @@ def extract_relevant_data(response) # Call to AWS STS endpoint using the provided authentication header def attempt_signed_request(signed_headers) - aws_request = URI("https://#{signed_headers['host']}/?Action=GetCallerIdentity&Version=2011-06-15") - begin - @client.get_response(aws_request, signed_headers) + region = extract_sts_region(signed_headers) + + # Attempt request using the discovered region and return immediately if successful + response = aws_call(region: region, headers: signed_headers) + return response if response.code.to_i == 200 + + # If the discovered region is `us-east-1`, fallback to the global endpoint + if region == 'us-east-1' + @logger.debug{LogMessages::Authentication::AuthnIam::RetryWithGlobalEndpoint.new} + fallback_response = aws_call(region: 'global', headers: signed_headers) + return fallback_response if fallback_response.code.to_i == 200 + end - # Handle any network failures with a generic verification error + return response + end + + def aws_call(region:, headers:) + host = if region == 'global' + 'sts.amazonaws.com' + else + "sts.#{region}.amazonaws.com" + end + aws_request = URI("https://#{host}/?Action=GetCallerIdentity&Version=2011-06-15") + begin + @client.get_response(aws_request, headers) rescue StandardError => e - raise(Errors::Authentication::AuthnIam::VerificationError.new(e)) + # Handle any network failures with a generic verification error + raise(Errors::Authentication::AuthnIam::VerificationError, e) end end @@ -76,6 +97,25 @@ def response_from_signed_request(aws_headers) body.dig('ErrorResponse', 'Error', 'Message').to_s.strip ) end + + # Extracts the STS region from the host header if it exists. + # If not, we use the authorization header's credential string, i.e.: + # Credential=AKIAIOSFODNN7EXAMPLE/20220830/us-east-1/sts/aws4_request + def extract_sts_region(signed_headers) + host = signed_headers['host'] + + if host == 'sts.amazonaws.com' + return 'global' + end + + match = host&.match(%r{sts.([\w\-]+).amazonaws.com}) + return match.captures.first if match + + match = signed_headers['authorization']&.match(%r{Credential=[^/]+/[^/]+/([^/]+)/}) + return match.captures.first if match + + raise Errors::Authentication::AuthnIam::InvalidAWSHeaders, 'Failed to extract AWS region from authorization header' + end end end end diff --git a/app/domain/authentication/authn_jwt/authenticator.rb b/app/domain/authentication/authn_jwt/authenticator.rb index f16e3e3dec..f7e8518bde 100644 --- a/app/domain/authentication/authn_jwt/authenticator.rb +++ b/app/domain/authentication/authn_jwt/authenticator.rb @@ -27,7 +27,7 @@ def call validate_origin validate_restrictions audit_success - @logger.debug(LogMessages::Authentication::AuthnJwt::JwtAuthenticationPassed.new) + @logger.debug{LogMessages::Authentication::AuthnJwt::JwtAuthenticationPassed.new} new_token rescue => e audit_failure(e) @@ -37,15 +37,15 @@ def call private def validate_and_decode_token - @logger.debug(LogMessages::Authentication::AuthnJwt::CallingValidateAndDecodeToken.new) + @logger.debug{LogMessages::Authentication::AuthnJwt::CallingValidateAndDecodeToken.new} @vendor_configuration.validate_and_decode_token - @logger.debug(LogMessages::Authentication::AuthnJwt::ValidateAndDecodeTokenPassed.new) + @logger.debug{LogMessages::Authentication::AuthnJwt::ValidateAndDecodeTokenPassed.new} end def get_jwt_identity_from_request - @logger.debug(LogMessages::Authentication::AuthnJwt::CallingGetJwtIdentity.new) + @logger.debug{LogMessages::Authentication::AuthnJwt::CallingGetJwtIdentity.new} jwt_identity - @logger.info(LogMessages::Authentication::AuthnJwt::FoundJwtIdentity.new(jwt_identity)) + @logger.debug{LogMessages::Authentication::AuthnJwt::FoundJwtIdentity.new(jwt_identity)} end def jwt_identity @@ -70,9 +70,9 @@ def validate_origin end def validate_restrictions - @logger.debug(LogMessages::Authentication::AuthnJwt::CallingValidateRestrictions.new) + @logger.debug{LogMessages::Authentication::AuthnJwt::CallingValidateRestrictions.new} @vendor_configuration.validate_restrictions - @logger.debug(LogMessages::Authentication::AuthnJwt::ValidateRestrictionsPassed.new) + @logger.debug{LogMessages::Authentication::AuthnJwt::ValidateRestrictionsPassed.new} end def audit_success diff --git a/app/domain/authentication/authn_jwt/identity_providers/create_identity_provider.rb b/app/domain/authentication/authn_jwt/identity_providers/create_identity_provider.rb index 6bd490d3cb..48806ffcf8 100644 --- a/app/domain/authentication/authn_jwt/identity_providers/create_identity_provider.rb +++ b/app/domain/authentication/authn_jwt/identity_providers/create_identity_provider.rb @@ -26,7 +26,7 @@ def call private def create_identity_provider - @logger.debug(LogMessages::Authentication::AuthnJwt::SelectingIdentityProviderInterface.new) + @logger.debug{LogMessages::Authentication::AuthnJwt::SelectingIdentityProviderInterface.new} if identity_should_be_in_token? and !identity_should_be_in_url? return identity_from_decoded_token_provider @@ -50,11 +50,11 @@ def identity_should_be_in_token? end def identity_from_decoded_token_provider - @logger.info( + @logger.debug{ LogMessages::Authentication::AuthnJwt::SelectedIdentityProviderInterface.new( TOKEN_IDENTITY_PROVIDER_INTERFACE_NAME ) - ) + } @identity_from_decoded_token_class.new( jwt_authenticator_input: @jwt_authenticator_input @@ -66,11 +66,11 @@ def identity_should_be_in_url? end def identity_from_url_provider - @logger.info( + @logger.debug{ LogMessages::Authentication::AuthnJwt::SelectedIdentityProviderInterface.new( URL_IDENTITY_PROVIDER_INTERFACE_NAME ) - ) + } @identity_from_url_provider_class.new( jwt_authenticator_input: @jwt_authenticator_input diff --git a/app/domain/authentication/authn_jwt/identity_providers/fetch_identity_path.rb b/app/domain/authentication/authn_jwt/identity_providers/fetch_identity_path.rb index e93cda41ac..de84c17780 100644 --- a/app/domain/authentication/authn_jwt/identity_providers/fetch_identity_path.rb +++ b/app/domain/authentication/authn_jwt/identity_providers/fetch_identity_path.rb @@ -22,7 +22,7 @@ def call private def fetch_identity_path - @logger.debug(LogMessages::Authentication::AuthnJwt::FetchingIdentityPath.new) + @logger.debug{LogMessages::Authentication::AuthnJwt::FetchingIdentityPath.new} identity_path end @@ -30,23 +30,23 @@ def identity_path return @identity_path if @identity_path if identity_path_resource_exists? - @logger.info( + @logger.debug{ LogMessages::Authentication::AuthnJwt::RetrievedResourceValue.new( identity_path_secret_value, IDENTITY_PATH_RESOURCE_NAME ) - ) + } @identity_path = identity_path_secret_value else - @logger.debug( + @logger.debug{ LogMessages::Authentication::AuthnJwt::IdentityPathNotConfigured.new( IDENTITY_PATH_RESOURCE_NAME ) - ) + } @identity_path = IDENTITY_PATH_DEFAULT_VALUE end - @logger.info(LogMessages::Authentication::AuthnJwt::FetchedIdentityPath.new(@identity_path)) + @logger.debug{LogMessages::Authentication::AuthnJwt::FetchedIdentityPath.new(@identity_path)} @identity_path end diff --git a/app/domain/authentication/authn_jwt/identity_providers/identity_from_decoded_token_provider.rb b/app/domain/authentication/authn_jwt/identity_providers/identity_from_decoded_token_provider.rb index 90ace1b83b..1802b5e9c5 100644 --- a/app/domain/authentication/authn_jwt/identity_providers/identity_from_decoded_token_provider.rb +++ b/app/domain/authentication/authn_jwt/identity_providers/identity_from_decoded_token_provider.rb @@ -15,11 +15,11 @@ module IdentityProviders inputs: %i[jwt_authenticator_input] ) do def call - @logger.debug( + @logger.debug{ LogMessages::Authentication::AuthnJwt::FetchingIdentityByInterface.new( TOKEN_IDENTITY_PROVIDER_INTERFACE_NAME ) - ) + } # Ensures token has id claim, and stores its value in @id_from_token. fetch_id_from_token @@ -36,12 +36,12 @@ def call # File.join('/a/b/', '/c/d/', '/e') => "/a/b/c/d/e" full_host_id = File.join(host_prefix, id_path, @id_from_token) - @logger.info( + @logger.debug{ LogMessages::Authentication::AuthnJwt::FetchedIdentityByInterface.new( full_host_id, TOKEN_IDENTITY_PROVIDER_INTERFACE_NAME ) - ) + } full_host_id end @@ -51,20 +51,20 @@ def call def fetch_id_from_token return @id_from_token if @id_from_token - @logger.debug( + @logger.debug{ LogMessages::Authentication::AuthnJwt::CheckingIdentityFieldExists.new(id_claim_key) - ) + } @id_from_token = id_claim_value id_claim_value_not_empty id_claim_value_is_string - @logger.debug( + @logger.debug{ LogMessages::Authentication::AuthnJwt::FoundJwtFieldInToken.new( id_claim_key, @id_from_token ) - ) + } @id_from_token end diff --git a/app/domain/authentication/authn_jwt/identity_providers/identity_from_url_provider.rb b/app/domain/authentication/authn_jwt/identity_providers/identity_from_url_provider.rb index ba6ae956d1..2df2bde298 100644 --- a/app/domain/authentication/authn_jwt/identity_providers/identity_from_url_provider.rb +++ b/app/domain/authentication/authn_jwt/identity_providers/identity_from_url_provider.rb @@ -14,19 +14,19 @@ module IdentityProviders def_delegators(:@jwt_authenticator_input, :username) def call - @logger.debug( + @logger.debug{ LogMessages::Authentication::AuthnJwt::FetchingIdentityByInterface.new( URL_IDENTITY_PROVIDER_INTERFACE_NAME ) - ) + } raise Errors::Authentication::AuthnJwt::IdentityMisconfigured unless username_exists? - @logger.info( + @logger.debug{ LogMessages::Authentication::AuthnJwt::FetchedIdentityByInterface.new( username, URL_IDENTITY_PROVIDER_INTERFACE_NAME ) - ) + } username end diff --git a/app/domain/authentication/authn_jwt/input_validation/parse_claim_aliases.rb b/app/domain/authentication/authn_jwt/input_validation/parse_claim_aliases.rb index c4fc918f61..942d9cd02c 100644 --- a/app/domain/authentication/authn_jwt/input_validation/parse_claim_aliases.rb +++ b/app/domain/authentication/authn_jwt/input_validation/parse_claim_aliases.rb @@ -13,11 +13,11 @@ module InputValidation inputs: %i[claim_aliases] ) do def call - @logger.debug(LogMessages::Authentication::AuthnJwt::ParsingClaimAliases.new(@claim_aliases)) + @logger.debug{LogMessages::Authentication::AuthnJwt::ParsingClaimAliases.new(@claim_aliases)} validate_claim_aliases_secret_value_exists validate_claim_aliases_value_string validate_claim_aliases_list_values - @logger.debug(LogMessages::Authentication::AuthnJwt::ParsedClaimAliases.new(alias_hash)) + @logger.debug{LogMessages::Authentication::AuthnJwt::ParsedClaimAliases.new(alias_hash)} alias_hash end @@ -100,7 +100,7 @@ def add_to_alias_hash(annotation_name, claim_name) raise Errors::Authentication::AuthnJwt::ClaimAliasDuplicationError.new('claim name', claim_name) unless value_set.add?(claim_name) - @logger.debug(LogMessages::Authentication::AuthnJwt::ClaimMapDefinition.new(annotation_name, claim_name)) + @logger.debug{LogMessages::Authentication::AuthnJwt::ClaimMapDefinition.new(annotation_name, claim_name)} alias_hash[annotation_name] = claim_name end diff --git a/app/domain/authentication/authn_jwt/input_validation/parse_enforced_claims.rb b/app/domain/authentication/authn_jwt/input_validation/parse_enforced_claims.rb index 821468abc8..2ed68c10f8 100644 --- a/app/domain/authentication/authn_jwt/input_validation/parse_enforced_claims.rb +++ b/app/domain/authentication/authn_jwt/input_validation/parse_enforced_claims.rb @@ -12,11 +12,11 @@ module InputValidation inputs: %i[enforced_claims] ) do def call - @logger.debug(LogMessages::Authentication::AuthnJwt::ParsingEnforcedClaims.new(@enforced_claims)) + @logger.debug{LogMessages::Authentication::AuthnJwt::ParsingEnforcedClaims.new(@enforced_claims)} validate_enforced_claims_exists validate_enforced_claims_list_format validate_enforced_claims_list_value - @logger.debug(LogMessages::Authentication::AuthnJwt::ParsedEnforcedClaims.new(parsed_enforced_claims_list)) + @logger.debug{LogMessages::Authentication::AuthnJwt::ParsedEnforcedClaims.new(parsed_enforced_claims_list)} parsed_enforced_claims_list end diff --git a/app/domain/authentication/authn_jwt/input_validation/validate_claim_name.rb b/app/domain/authentication/authn_jwt/input_validation/validate_claim_name.rb index 13af3a2661..cc18996d4e 100644 --- a/app/domain/authentication/authn_jwt/input_validation/validate_claim_name.rb +++ b/app/domain/authentication/authn_jwt/input_validation/validate_claim_name.rb @@ -11,11 +11,11 @@ module InputValidation inputs: %i[claim_name] ) do def call - @logger.debug(LogMessages::Authentication::AuthnJwt::ValidatingClaimName.new(@claim_name)) + @logger.debug{LogMessages::Authentication::AuthnJwt::ValidatingClaimName.new(@claim_name)} validate_claim_name_exists validate_claim_name_value validate_claim_is_allowed - @logger.debug(LogMessages::Authentication::AuthnJwt::ValidatedClaimName.new(@claim_name)) + @logger.debug{LogMessages::Authentication::AuthnJwt::ValidatedClaimName.new(@claim_name)} end private @@ -38,7 +38,7 @@ def valid_claim_name_regex end def validate_claim_is_allowed - @logger.debug(LogMessages::Authentication::AuthnJwt::ClaimsDenyListValue.new(@deny_claims_list_value)) + @logger.debug{LogMessages::Authentication::AuthnJwt::ClaimsDenyListValue.new(@deny_claims_list_value)} return if @deny_claims_list_value.blank? if @deny_claims_list_value.include?(@claim_name) diff --git a/app/domain/authentication/authn_jwt/orchestrate_authentication.rb b/app/domain/authentication/authn_jwt/orchestrate_authentication.rb index b45bdca9e7..45d3c93b72 100644 --- a/app/domain/authentication/authn_jwt/orchestrate_authentication.rb +++ b/app/domain/authentication/authn_jwt/orchestrate_authentication.rb @@ -28,7 +28,7 @@ def validate_uri_based_parameters end def authenticate_jwt - @logger.info(LogMessages::Authentication::AuthnJwt::JwtAuthenticatorEntryPoint.new(@authenticator_input.authenticator_name)) + @logger.debug{LogMessages::Authentication::AuthnJwt::JwtAuthenticatorEntryPoint.new(@authenticator_input.authenticator_name)} @jwt_authenticator.call( vendor_configuration: vendor_configuration, diff --git a/app/domain/authentication/authn_jwt/parse_claim_path.rb b/app/domain/authentication/authn_jwt/parse_claim_path.rb index 3793eb3712..c9fa14271c 100644 --- a/app/domain/authentication/authn_jwt/parse_claim_path.rb +++ b/app/domain/authentication/authn_jwt/parse_claim_path.rb @@ -10,14 +10,14 @@ def initialize(logger: Rails.logger) end def call(claim:, parts_separator: PATH_DELIMITER) - @logger.debug(LogMessages::Authentication::AuthnJwt::ClaimPathParsingStart.new(claim)) + @logger.debug{LogMessages::Authentication::AuthnJwt::ClaimPathParsingStart.new(claim)} raise Errors::Authentication::AuthnJwt::InvalidClaimPath.new(claim, PURE_NESTED_CLAIM_NAME_REGEX) if claim.nil? || !claim.match?(PURE_NESTED_CLAIM_NAME_REGEX) result = claim .split(parts_separator) - @logger.debug(LogMessages::Authentication::AuthnJwt::ClaimPathParsingEnd.new(result)) + @logger.debug{LogMessages::Authentication::AuthnJwt::ClaimPathParsingEnd.new(result)} result end end diff --git a/app/domain/authentication/authn_jwt/restriction_validation/create_constraints.rb b/app/domain/authentication/authn_jwt/restriction_validation/create_constraints.rb index c5cee7c60a..66448c98b3 100644 --- a/app/domain/authentication/authn_jwt/restriction_validation/create_constraints.rb +++ b/app/domain/authentication/authn_jwt/restriction_validation/create_constraints.rb @@ -23,7 +23,7 @@ module RestrictionValidation # These is command class so only call is called from outside. Other functions are needed here. # :reek:TooManyMethods def call - @logger.info(LogMessages::Authentication::AuthnJwt::CreateContraintsFromPolicy.new) + @logger.debug{LogMessages::Authentication::AuthnJwt::CreateContraintsFromPolicy.new} fetch_enforced_claims fetch_claim_aliases map_enforced_claims @@ -32,7 +32,7 @@ def call add_required_constraint add_non_permitted_constraint create_multiple_constraint - @logger.info(LogMessages::Authentication::AuthnJwt::CreatedConstraintsFromPolicy.new) + @logger.debug{LogMessages::Authentication::AuthnJwt::CreatedConstraintsFromPolicy.new} multiple_constraint end @@ -63,7 +63,7 @@ def mapped_enforced_claims def convert_claim(claim) if claim_aliases.include?(claim) claim_reference = claim_aliases[claim] - @logger.debug(LogMessages::Authentication::AuthnJwt::ConvertingClaimAccordingToAlias.new(claim, claim_reference)) + @logger.debug{LogMessages::Authentication::AuthnJwt::ConvertingClaimAccordingToAlias.new(claim, claim_reference)} return claim_reference end claim @@ -104,7 +104,7 @@ def claim_aliases end def required_constraint - @logger.debug(LogMessages::Authentication::AuthnJwt::ConstraintsFromEnforcedClaims.new(mapped_enforced_claims)) + @logger.debug{LogMessages::Authentication::AuthnJwt::ConstraintsFromEnforcedClaims.new(mapped_enforced_claims)} @required_constraint ||= @required_constraint_class.new( required: mapped_enforced_claims ) diff --git a/app/domain/authentication/authn_jwt/restriction_validation/fetch_claim_aliases.rb b/app/domain/authentication/authn_jwt/restriction_validation/fetch_claim_aliases.rb index e03aa3ca87..7b383acbe7 100644 --- a/app/domain/authentication/authn_jwt/restriction_validation/fetch_claim_aliases.rb +++ b/app/domain/authentication/authn_jwt/restriction_validation/fetch_claim_aliases.rb @@ -18,7 +18,7 @@ module RestrictionValidation def_delegators(:@jwt_authenticator_input, :service_id, :authenticator_name, :account) def call - @logger.debug(LogMessages::Authentication::AuthnJwt::FetchingClaimAliases.new) + @logger.debug{LogMessages::Authentication::AuthnJwt::FetchingClaimAliases.new} return empty_claim_aliases unless claim_aliases_resource_exists? @@ -29,7 +29,7 @@ def call private def empty_claim_aliases - @logger.debug(LogMessages::Authentication::AuthnJwt::NotConfiguredClaimAliases.new) + @logger.debug{LogMessages::Authentication::AuthnJwt::NotConfiguredClaimAliases.new} @empty_claim_aliases ||= {} end @@ -65,7 +65,7 @@ def claim_aliases return @claim_aliases if @claim_aliases @claim_aliases ||= @parse_claim_aliases.call(claim_aliases: claim_aliases_secret_value) - @logger.info(LogMessages::Authentication::AuthnJwt::FetchedClaimAliases.new(@claim_aliases)) + @logger.debug{LogMessages::Authentication::AuthnJwt::FetchedClaimAliases.new(@claim_aliases)} @claim_aliases end diff --git a/app/domain/authentication/authn_jwt/restriction_validation/fetch_enforced_claims.rb b/app/domain/authentication/authn_jwt/restriction_validation/fetch_enforced_claims.rb index 71aa50d33e..4c986cf844 100644 --- a/app/domain/authentication/authn_jwt/restriction_validation/fetch_enforced_claims.rb +++ b/app/domain/authentication/authn_jwt/restriction_validation/fetch_enforced_claims.rb @@ -18,7 +18,7 @@ module RestrictionValidation def_delegators(:@jwt_authenticator_input, :service_id, :authenticator_name, :account) def call - @logger.debug(LogMessages::Authentication::AuthnJwt::FetchingEnforcedClaims.new) + @logger.debug{LogMessages::Authentication::AuthnJwt::FetchingEnforcedClaims.new} return empty_enforced_claims unless enforced_claims_resource_exists? @@ -40,7 +40,7 @@ def enforced_claims_resource_exists? end def empty_enforced_claims - @logger.debug(LogMessages::Authentication::AuthnJwt::NotConfiguredEnforcedClaims.new) + @logger.debug{LogMessages::Authentication::AuthnJwt::NotConfiguredEnforcedClaims.new} @empty_enforced_claims ||= [] end @@ -61,7 +61,7 @@ def parse_enforced_claims_secret_value return @parse_enforced_claims_secret_value if @parse_enforced_claims_secret_value @parse_enforced_claims_secret_value ||= @parse_enforced_claims.call(enforced_claims: enforced_claims_secret_value) - @logger.info(LogMessages::Authentication::AuthnJwt::FetchedEnforcedClaims.new(@parse_enforced_claims_secret_value)) + @logger.debug{LogMessages::Authentication::AuthnJwt::FetchedEnforcedClaims.new(@parse_enforced_claims_secret_value)} @parse_enforced_claims_secret_value end diff --git a/app/domain/authentication/authn_jwt/restriction_validation/validate_restrictions_one_to_one.rb b/app/domain/authentication/authn_jwt/restriction_validation/validate_restrictions_one_to_one.rb index 73474fa814..88f3c51165 100644 --- a/app/domain/authentication/authn_jwt/restriction_validation/validate_restrictions_one_to_one.rb +++ b/app/domain/authentication/authn_jwt/restriction_validation/validate_restrictions_one_to_one.rb @@ -46,7 +46,7 @@ def valid_restriction?(restriction) def claim_name(annotation_name) claim_name = @aliased_claims.fetch(annotation_name, annotation_name) - @logger.debug(LogMessages::Authentication::AuthnJwt::ClaimMapUsage.new(annotation_name, claim_name)) unless + @logger.debug{LogMessages::Authentication::AuthnJwt::ClaimMapUsage.new(annotation_name, claim_name)} unless annotation_name == claim_name claim_name end diff --git a/app/domain/authentication/authn_jwt/signing_key/create_jwks_from_http_response.rb b/app/domain/authentication/authn_jwt/signing_key/create_jwks_from_http_response.rb index 99b6921a75..543dd1a213 100644 --- a/app/domain/authentication/authn_jwt/signing_key/create_jwks_from_http_response.rb +++ b/app/domain/authentication/authn_jwt/signing_key/create_jwks_from_http_response.rb @@ -26,7 +26,7 @@ def validate_response_success end def create_jwks_from_http_response - @logger.debug(LogMessages::Authentication::AuthnJwt::CreatingJwksFromHttpResponse.new) + @logger.debug{LogMessages::Authentication::AuthnJwt::CreatingJwksFromHttpResponse.new} parse_jwks_response end @@ -50,7 +50,7 @@ def parse_jwks_response end validate_keys_not_empty(keys, encoded_body) - @logger.debug(LogMessages::Authentication::AuthnJwt::CreatedJwks.new) + @logger.debug{LogMessages::Authentication::AuthnJwt::CreatedJwks.new} { keys: @jwk_set_class.new(keys) } end diff --git a/app/domain/authentication/authn_jwt/signing_key/create_signing_key_provider.rb b/app/domain/authentication/authn_jwt/signing_key/create_signing_key_provider.rb index e02ef09c01..6a8056b0b2 100644 --- a/app/domain/authentication/authn_jwt/signing_key/create_signing_key_provider.rb +++ b/app/domain/authentication/authn_jwt/signing_key/create_signing_key_provider.rb @@ -24,7 +24,7 @@ module SigningKey inputs: %i[authenticator_input] ) do def call - @logger.debug(LogMessages::Authentication::AuthnJwt::SelectingSigningKeyInterface.new) + @logger.debug{LogMessages::Authentication::AuthnJwt::SelectingSigningKeyInterface.new} build_signing_key_settings create_signing_key_provider end @@ -61,19 +61,20 @@ def create_signing_key_provider end def fetch_provider_uri_signing_key - @logger.info( + @logger.debug{ LogMessages::Authentication::AuthnJwt::SelectedSigningKeyInterface.new(PROVIDER_URI_INTERFACE_NAME) - ) + } @fetch_provider_uri_signing_key ||= @fetch_provider_uri_signing_key_class.new( provider_uri: signing_key_settings.uri, + ca_certificate: signing_key_settings.ca_cert, fetch_signing_key: @fetch_signing_key ) end def fetch_jwks_uri_signing_key - @logger.info( + @logger.debug{ LogMessages::Authentication::AuthnJwt::SelectedSigningKeyInterface.new(JWKS_URI_INTERFACE_NAME) - ) + } @fetch_jwks_uri_signing_key ||= @fetch_jwks_uri_signing_key_class.new( jwks_uri: signing_key_settings.uri, cert_store: signing_key_settings.cert_store, @@ -82,9 +83,9 @@ def fetch_jwks_uri_signing_key end def fetch_public_keys_signing_key - @logger.info( + @logger.debug{ LogMessages::Authentication::AuthnJwt::SelectedSigningKeyInterface.new(PUBLIC_KEYS_INTERFACE_NAME) - ) + } @fetch_public_keys_signing_key ||= @fetch_public_keys_signing_key_class.new( signing_keys: signing_key_settings.signing_keys ) diff --git a/app/domain/authentication/authn_jwt/signing_key/fetch_jwks_uri_signing_key.rb b/app/domain/authentication/authn_jwt/signing_key/fetch_jwks_uri_signing_key.rb index df5026f2ea..f3fe3ab857 100644 --- a/app/domain/authentication/authn_jwt/signing_key/fetch_jwks_uri_signing_key.rb +++ b/app/domain/authentication/authn_jwt/signing_key/fetch_jwks_uri_signing_key.rb @@ -48,13 +48,13 @@ def jwks_keys return @jwks_keys if defined?(@jwks_keys) uri = URI(@jwks_uri) - @logger.info(LogMessages::Authentication::AuthnJwt::FetchingJwksFromProvider.new(@jwks_uri)) + @logger.debug{LogMessages::Authentication::AuthnJwt::FetchingJwksFromProvider.new(@jwks_uri)} @jwks_keys = net_http_start( uri.host, uri.port, uri.scheme == 'https' ) { |http| http.get(uri) } - @logger.debug(LogMessages::Authentication::AuthnJwt::FetchJwtUriKeysSuccess.new) + @logger.debug{LogMessages::Authentication::AuthnJwt::FetchJwtUriKeysSuccess.new} rescue => e raise Errors::Authentication::AuthnJwt::FetchJwksKeysFailed.new( @jwks_uri, diff --git a/app/domain/authentication/authn_jwt/signing_key/fetch_provider_uri_signing_key.rb b/app/domain/authentication/authn_jwt/signing_key/fetch_provider_uri_signing_key.rb index 9f2f35675a..a866a49043 100644 --- a/app/domain/authentication/authn_jwt/signing_key/fetch_provider_uri_signing_key.rb +++ b/app/domain/authentication/authn_jwt/signing_key/fetch_provider_uri_signing_key.rb @@ -7,6 +7,7 @@ class FetchProviderUriSigningKey def initialize( provider_uri:, fetch_signing_key:, + ca_certificate: nil, discover_identity_provider: Authentication::OAuth::DiscoverIdentityProvider.new, logger: Rails.logger ) @@ -14,6 +15,7 @@ def initialize( @discover_identity_provider = discover_identity_provider @provider_uri = provider_uri + @ca_certificate = ca_certificate @fetch_signing_key = fetch_signing_key end @@ -33,19 +35,20 @@ def fetch_signing_key private def discover_provider - @logger.info(LogMessages::Authentication::AuthnJwt::FetchingJwksFromProvider.new(@provider_uri)) + @logger.debug{LogMessages::Authentication::AuthnJwt::FetchingJwksFromProvider.new(@provider_uri)} discovered_provider end def discovered_provider @discovered_provider ||= @discover_identity_provider.call( - provider_uri: @provider_uri + provider_uri: @provider_uri, + ca_cert: @ca_certificate ) end def fetch_provider_keys keys = { keys: discovered_provider.jwks } - @logger.debug(LogMessages::Authentication::OAuth::FetchProviderKeysSuccess.new) + @logger.debug{LogMessages::Authentication::OAuth::FetchProviderKeysSuccess.new} keys rescue => e raise Errors::Authentication::OAuth::FetchProviderKeysFailed.new( diff --git a/app/domain/authentication/authn_jwt/signing_key/fetch_public_keys_signing_key.rb b/app/domain/authentication/authn_jwt/signing_key/fetch_public_keys_signing_key.rb index 3feacfbb3a..8393f32130 100644 --- a/app/domain/authentication/authn_jwt/signing_key/fetch_public_keys_signing_key.rb +++ b/app/domain/authentication/authn_jwt/signing_key/fetch_public_keys_signing_key.rb @@ -13,10 +13,10 @@ def initialize( end def call(*) - @logger.info(LogMessages::Authentication::AuthnJwt::ParsingStaticSigningKeys.new) + @logger.debug{LogMessages::Authentication::AuthnJwt::ParsingStaticSigningKeys.new} public_signing_keys = Authentication::AuthnJwt::SigningKey::PublicSigningKeys.new(JSON.parse(@signing_keys)) public_signing_keys.validate! - @logger.debug(LogMessages::Authentication::AuthnJwt::ParsedStaticSigningKeys.new) + @logger.debug{LogMessages::Authentication::AuthnJwt::ParsedStaticSigningKeys.new} { keys: JSON::JWK::Set.new(public_signing_keys.value) } end end diff --git a/app/domain/authentication/authn_jwt/signing_key/signing_key_settings.rb b/app/domain/authentication/authn_jwt/signing_key/signing_key_settings.rb index 353090faff..9e54d7313c 100644 --- a/app/domain/authentication/authn_jwt/signing_key/signing_key_settings.rb +++ b/app/domain/authentication/authn_jwt/signing_key/signing_key_settings.rb @@ -4,18 +4,20 @@ module SigningKey # This class is responsible for JWKS fetching related settings of the authenticator class SigningKeySettings - attr_reader :type, :uri, :cert_store, :signing_keys + attr_reader :type, :uri, :cert_store, :signing_keys, :ca_cert def initialize( type:, uri: nil, cert_store: nil, - signing_keys: nil + signing_keys: nil, + ca_cert: nil ) @type = type @uri = uri @cert_store = cert_store @signing_keys = signing_keys + @ca_cert = ca_cert end end end diff --git a/app/domain/authentication/authn_jwt/signing_key/signing_key_settings_builder.rb b/app/domain/authentication/authn_jwt/signing_key/signing_key_settings_builder.rb index 2eea8f531f..bacf0bece7 100644 --- a/app/domain/authentication/authn_jwt/signing_key/signing_key_settings_builder.rb +++ b/app/domain/authentication/authn_jwt/signing_key/signing_key_settings_builder.rb @@ -7,7 +7,7 @@ module SigningKey JWKS_PROVIDER_URI_SIGNING_PAIR = "#{JWKS_URI_RESOURCE_NAME} and #{PROVIDER_URI_RESOURCE_NAME} cannot be defined simultaneously".freeze JWKS_URI_PUBLIC_KEYS_PAIR = "#{JWKS_URI_RESOURCE_NAME} and #{PUBLIC_KEYS_RESOURCE_NAME} cannot be defined simultaneously".freeze PUBLIC_KEYS_PROVIDER_URI_PAIR = "#{PUBLIC_KEYS_RESOURCE_NAME} and #{PROVIDER_URI_RESOURCE_NAME} cannot be defined simultaneously".freeze - CERT_STORE_ONLY_WITH_JWKS_URI = "#{CA_CERT_RESOURCE_NAME} can only be defined together with #{JWKS_URI_RESOURCE_NAME}".freeze + CERT_STORE_ONLY_WITH_JWKS_URI_OR_PROVIDER_URI = "#{CA_CERT_RESOURCE_NAME} can only be defined together with #{JWKS_URI_RESOURCE_NAME} or #{PROVIDER_URI_RESOURCE_NAME}".freeze PUBLIC_KEYS_HAVE_ISSUER = "#{ISSUER_RESOURCE_NAME} is mandatory when #{PUBLIC_KEYS_RESOURCE_NAME} is defined".freeze # fetches signing key settings, validates and builds SigningKeysSettings object @@ -26,7 +26,7 @@ def call def validate_signing_key_parameters single_signing_key_source - cert_store_only_with_jwks_uri + cert_store_only_with_jwks_uri_or_provider_uri public_keys_have_issuer end @@ -45,7 +45,7 @@ def check_no_signing_keys_source end def check_all_signing_keys_sources - return unless jwks_uri && public_keys && provider_uri + return unless jwks_uri && public_keys && provider_uri raise Errors::Authentication::AuthnJwt::InvalidSigningKeySettings, ALL_SIGNING_KEYS_SOURCES end @@ -68,10 +68,10 @@ def check_public_keys_provider_uri_pair raise Errors::Authentication::AuthnJwt::InvalidSigningKeySettings, PUBLIC_KEYS_PROVIDER_URI_PAIR end - def cert_store_only_with_jwks_uri - return unless ca_cert && !jwks_uri + def cert_store_only_with_jwks_uri_or_provider_uri + return unless ca_cert && (!jwks_uri && !provider_uri) - raise Errors::Authentication::AuthnJwt::InvalidSigningKeySettings, CERT_STORE_ONLY_WITH_JWKS_URI + raise Errors::Authentication::AuthnJwt::InvalidSigningKeySettings, CERT_STORE_ONLY_WITH_JWKS_URI_OR_PROVIDER_URI end def public_keys_have_issuer @@ -85,7 +85,8 @@ def signing_key_settings uri: signing_key_settings_uri, type: signing_key_settings_type, cert_store: signing_key_settings_cert_store, - signing_keys: public_keys + signing_keys: public_keys, + ca_cert: ca_cert ) end diff --git a/app/domain/authentication/authn_jwt/v2/strategy.rb b/app/domain/authentication/authn_jwt/v2/strategy.rb new file mode 100644 index 0000000000..65dcbd458c --- /dev/null +++ b/app/domain/authentication/authn_jwt/v2/strategy.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +module Authentication + module AuthnJwt + module V2 + class Strategy + def initialize( + authenticator:, + http_client: ::Authentication::Util::NetworkTransporter, + jwt_client: JWT, + cache: Rails.cache, + logger: Rails.logger, + digest: Digest::SHA1 + ) + @authenticator = authenticator + @http_client = http_client + @jwt_client = jwt_client + + @logger = logger + @cache = cache + @digest = digest + + @success = ::SuccessResponse + @failure = ::FailureResponse + end + + # rubocop:disable Lint/UnusedMethodArgument + def callback(parameters:, request_body: nil) + raise 'Not Implemented' + end + # rubocop:enable Lint/UnusedMethodArgument + + private + + def cache_key(url) + # Include a digest of the url to ensure cache is expired if url changes + @cache_key ||= "authenticators/#{@authenticator.type}/#{@authenticator.account}-#{@authenticator.service_id}/jwks-json-#{@digest.hexdigest(url)}" + end + + def decode_jwt(jwt:, issuer: nil, audience: nil, jwks_uri: nil) + jwt_args = { + algorithms: %w[RS256 RS384 RS512], + verify_iat: true + }.tap do |args| + if jwks_uri.present? + args[:jwks] = jwk_loader(jwks_uri) + end + if issuer.present? + args[:iss] = issuer + args[:verify_iss] = true + end + if audience.present? + args[:aud] = issuer + args[:verify_aud] = true + end + end + begin + @success.new( + @jwt_client.decode( + jwt, + nil, + true, # Verify the signature of this token + **jwt_args + ).first + ) + rescue => e + @failure.new(e.message, exception: e, status: :bad_request) + end + end + + def jwk_loader(jwks_url) + ->(options) { jwks(jwks_url: jwks_url, force: options[:invalidate]) || {} } + end + + def fetch_jwks(url) + client = @http_client.new( + hostname: url, + ca_certificate: @authenticator.ca_cert + ) + result = client.get(url).bind do |response| + return response + end + # Throw exception if we fail to retrieve the JWKS endpoint. This enables + # us to break and prevent the cache from being created. + raise result.error + end + + # Caches the JWKS response. This will be expired if the key has + # changed (and the signing key validation fails). + def jwks(jwks_url:, force: false) + @cache.fetch(cache_key(jwks_url), force: force, skip_nil: true) do + fetch_jwks(jwks_url) + end&.deep_symbolize_keys + end + end + end + end +end diff --git a/app/domain/authentication/authn_jwt/validate_and_decode/fetch_audience_value.rb b/app/domain/authentication/authn_jwt/validate_and_decode/fetch_audience_value.rb index 5c2c49d909..d9bbbef1a1 100644 --- a/app/domain/authentication/authn_jwt/validate_and_decode/fetch_audience_value.rb +++ b/app/domain/authentication/authn_jwt/validate_and_decode/fetch_audience_value.rb @@ -16,14 +16,14 @@ module ValidateAndDecode def_delegators(:@authenticator_input, :service_id, :authenticator_name, :account) def call - @logger.debug(LogMessages::Authentication::AuthnJwt::FetchingAudienceValue.new) + @logger.debug{LogMessages::Authentication::AuthnJwt::FetchingAudienceValue.new} return empty_audience_value unless audience_resource_exists? fetch_audience_secret_value validate_audience_secret_has_value - @logger.info(LogMessages::Authentication::AuthnJwt::FetchedAudienceValue.new(audience_secret_value)) + @logger.debug{LogMessages::Authentication::AuthnJwt::FetchedAudienceValue.new(audience_secret_value)} audience_secret_value end @@ -42,7 +42,7 @@ def audience_resource_exists? end def empty_audience_value - @logger.debug(LogMessages::Authentication::AuthnJwt::FetchingAudienceValue.new) + @logger.debug{LogMessages::Authentication::AuthnJwt::FetchingAudienceValue.new} '' end diff --git a/app/domain/authentication/authn_jwt/validate_and_decode/fetch_issuer_value.rb b/app/domain/authentication/authn_jwt/validate_and_decode/fetch_issuer_value.rb index e0540b1198..3f0d79421d 100644 --- a/app/domain/authentication/authn_jwt/validate_and_decode/fetch_issuer_value.rb +++ b/app/domain/authentication/authn_jwt/validate_and_decode/fetch_issuer_value.rb @@ -19,7 +19,7 @@ module ValidateAndDecode def_delegators(:@authenticator_input, :service_id, :authenticator_name, :account) def call - @logger.debug(LogMessages::Authentication::AuthnJwt::FetchingIssuerConfigurationValue.new) + @logger.debug{LogMessages::Authentication::AuthnJwt::FetchingIssuerConfigurationValue.new} fetch_issuer_value @issuer_value @@ -38,24 +38,24 @@ def call # In case the resource is configured but the not initialized with secret, throw an error def fetch_issuer_value if issuer_resource_exists? - @logger.info(LogMessages::Authentication::AuthnJwt::IssuerResourceNameConfiguration.new(ISSUER_RESOURCE_NAME)) + @logger.debug{LogMessages::Authentication::AuthnJwt::IssuerResourceNameConfiguration.new(ISSUER_RESOURCE_NAME)} @issuer_value = issuer_secret_value else validate_issuer_configuration if provider_uri_resource_exists? - @logger.info(LogMessages::Authentication::AuthnJwt::IssuerResourceNameConfiguration.new(PROVIDER_URI_RESOURCE_NAME)) + @logger.debug{LogMessages::Authentication::AuthnJwt::IssuerResourceNameConfiguration.new(PROVIDER_URI_RESOURCE_NAME)} @issuer_value = provider_uri_secret_value elsif jwks_uri_resource_exists? - @logger.info(LogMessages::Authentication::AuthnJwt::IssuerResourceNameConfiguration.new(JWKS_URI_RESOURCE_NAME)) + @logger.debug{LogMessages::Authentication::AuthnJwt::IssuerResourceNameConfiguration.new(JWKS_URI_RESOURCE_NAME)} @issuer_value = fetch_issuer_from_jwks_uri_secret end end - @logger.info(LogMessages::Authentication::AuthnJwt::RetrievedIssuerValue.new(@issuer_value)) + @logger.debug{LogMessages::Authentication::AuthnJwt::RetrievedIssuerValue.new(@issuer_value)} end def issuer_resource_exists? @@ -131,7 +131,7 @@ def provider_uri_secret end def fetch_issuer_from_jwks_uri_secret - @logger.debug(LogMessages::Authentication::AuthnJwt::ParsingIssuerFromUri.new(jwks_uri_secret_value)) + @logger.debug{LogMessages::Authentication::AuthnJwt::ParsingIssuerFromUri.new(jwks_uri_secret_value)} if issuer_from_jwks_uri_secret.blank? raise Errors::Authentication::AuthnJwt::FailedToParseHostnameFromUri, jwks_uri_secret_value diff --git a/app/domain/authentication/authn_jwt/validate_and_decode/fetch_jwt_claims_to_validate.rb b/app/domain/authentication/authn_jwt/validate_and_decode/fetch_jwt_claims_to_validate.rb index e699e5469a..99587b501a 100644 --- a/app/domain/authentication/authn_jwt/validate_and_decode/fetch_jwt_claims_to_validate.rb +++ b/app/domain/authentication/authn_jwt/validate_and_decode/fetch_jwt_claims_to_validate.rb @@ -17,14 +17,14 @@ module ValidateAndDecode inputs: %i[authenticator_input decoded_token] ) do def call - @logger.debug(LogMessages::Authentication::AuthnJwt::FetchingJwtClaimsToValidate.new) + @logger.debug{LogMessages::Authentication::AuthnJwt::FetchingJwtClaimsToValidate.new} validate_decoded_token_exists fetch_jwt_claims_to_validate - @logger.info( + @logger.debug{ LogMessages::Authentication::AuthnJwt::FetchedJwtClaimsToValidate.new( jwt_claims_names_to_validate ) - ) + } jwt_claims_to_validate end @@ -55,14 +55,14 @@ def audience_value def add_optional_claims_to_jwt_claims_list OPTIONAL_CLAIMS.each do |optional_claim| - @logger.debug(LogMessages::Authentication::AuthnJwt::CheckingJwtClaimToValidate.new(optional_claim)) + @logger.debug{LogMessages::Authentication::AuthnJwt::CheckingJwtClaimToValidate.new(optional_claim)} add_to_jwt_claims_list(optional_claim) if @decoded_token[optional_claim] end end def add_to_jwt_claims_list(claim) - @logger.debug(LogMessages::Authentication::AuthnJwt::AddingJwtClaimToValidate.new(claim)) + @logger.debug{LogMessages::Authentication::AuthnJwt::AddingJwtClaimToValidate.new(claim)} jwt_claims_to_validate.push( @jwt_claim_class.new( diff --git a/app/domain/authentication/authn_jwt/validate_and_decode/get_verification_option_by_jwt_claim.rb b/app/domain/authentication/authn_jwt/validate_and_decode/get_verification_option_by_jwt_claim.rb index 6b0f1df412..3984f68daf 100644 --- a/app/domain/authentication/authn_jwt/validate_and_decode/get_verification_option_by_jwt_claim.rb +++ b/app/domain/authentication/authn_jwt/validate_and_decode/get_verification_option_by_jwt_claim.rb @@ -29,7 +29,7 @@ def validate_claim_exists end def get_verification_option_by_jwt_claim - @logger.debug(LogMessages::Authentication::AuthnJwt::ConvertingJwtClaimToVerificationOption.new(claim_name)) + @logger.debug{LogMessages::Authentication::AuthnJwt::ConvertingJwtClaimToVerificationOption.new(claim_name)} case claim_name when EXP_CLAIM_NAME, NBF_CLAIM_NAME @@ -48,12 +48,12 @@ def get_verification_option_by_jwt_claim raise Errors::Authentication::AuthnJwt::UnsupportedClaim, claim_name end - @logger.debug( + @logger.debug{ LogMessages::Authentication::AuthnJwt::ConvertedJwtClaimToVerificationOption.new( claim_name, @verification_option.to_s ) - ) + } @verification_option end diff --git a/app/domain/authentication/authn_jwt/validate_and_decode/validate_and_decode_token.rb b/app/domain/authentication/authn_jwt/validate_and_decode/validate_and_decode_token.rb index 9196f44124..24c6521b3a 100644 --- a/app/domain/authentication/authn_jwt/validate_and_decode/validate_and_decode_token.rb +++ b/app/domain/authentication/authn_jwt/validate_and_decode/validate_and_decode_token.rb @@ -18,13 +18,13 @@ module ValidateAndDecode extend(Forwardable) def call - @logger.debug(LogMessages::Authentication::AuthnJwt::ValidatingToken.new) + @logger.debug{LogMessages::Authentication::AuthnJwt::ValidatingToken.new} validate_token_exists fetch_signing_key validate_signature fetch_jwt_claims_to_validate validate_claims - @logger.debug(LogMessages::Authentication::AuthnJwt::ValidatedToken.new) + @logger.debug{LogMessages::Authentication::AuthnJwt::ValidatedToken.new} decoded_and_validated_token_with_claims end @@ -45,22 +45,22 @@ def fetch_signing_key(force_fetch: false) @jwks = signing_key_provider.call( force_fetch: force_fetch ) - @logger.debug(LogMessages::Authentication::AuthnJwt::SigningKeysFetchedFromCache.new) + @logger.debug{LogMessages::Authentication::AuthnJwt::SigningKeysFetchedFromCache.new} end def validate_signature - @logger.debug(LogMessages::Authentication::AuthnJwt::ValidatingTokenSignature.new) + @logger.debug{LogMessages::Authentication::AuthnJwt::ValidatingTokenSignature.new} ensure_keys_are_fresh fetch_decoded_token_for_signature_only - @logger.debug(LogMessages::Authentication::AuthnJwt::ValidatedTokenSignature.new) + @logger.debug{LogMessages::Authentication::AuthnJwt::ValidatedTokenSignature.new} end def ensure_keys_are_fresh fetch_decoded_token_for_signature_only rescue - @logger.debug( + @logger.debug{ LogMessages::Authentication::AuthnJwt::ValidateSigningKeysAreUpdated.new - ) + } # maybe failed due to keys rotation. Force cache to read it again fetch_signing_key(force_fetch: true) end @@ -99,7 +99,7 @@ def claims_to_validate end def validate_claims - @logger.debug(LogMessages::Authentication::AuthnJwt::ValidatingTokenClaims.new) + @logger.debug{LogMessages::Authentication::AuthnJwt::ValidatingTokenClaims.new} claims_to_validate.each do |jwt_claim| claim_name = jwt_claim.name @@ -112,7 +112,7 @@ def validate_claims end validate_token_with_claims - @logger.debug(LogMessages::Authentication::AuthnJwt::ValidatedTokenClaims.new) + @logger.debug{LogMessages::Authentication::AuthnJwt::ValidatedTokenClaims.new} end def add_to_verification_options_with_claims(verification_option) diff --git a/app/domain/authentication/authn_jwt/validate_status.rb b/app/domain/authentication/authn_jwt/validate_status.rb index 106823b5b0..4c0db64917 100644 --- a/app/domain/authentication/authn_jwt/validate_status.rb +++ b/app/domain/authentication/authn_jwt/validate_status.rb @@ -49,13 +49,13 @@ def validate_account_exists @validate_account_exists.( account: account ) - @logger.debug(LogMessages::Authentication::AuthnJwt::ValidatedAccountExists.new) + @logger.debug{LogMessages::Authentication::AuthnJwt::ValidatedAccountExists.new} end def validate_service_id_exists raise Errors::Authentication::AuthnJwt::ServiceIdMissing unless service_id - @logger.debug(LogMessages::Authentication::AuthnJwt::ValidatedServiceIdExists.new) + @logger.debug{LogMessages::Authentication::AuthnJwt::ValidatedServiceIdExists.new} end def validate_user_has_access_to_status_webservice @@ -65,7 +65,7 @@ def validate_user_has_access_to_status_webservice user_id: username, privilege: 'read' ) - @logger.debug(LogMessages::Authentication::AuthnJwt::ValidatedUserHasAccessToStatusWebservice.new) + @logger.debug{LogMessages::Authentication::AuthnJwt::ValidatedUserHasAccessToStatusWebservice.new} end def validate_authenticator_webservice_exists @@ -73,7 +73,7 @@ def validate_authenticator_webservice_exists webservice: webservice, account: account ) - @logger.debug(LogMessages::Authentication::AuthnJwt::ValidatedAuthenticatorWebServiceExists.new) + @logger.debug{LogMessages::Authentication::AuthnJwt::ValidatedAuthenticatorWebServiceExists.new} end def validate_webservice_is_whitelisted @@ -82,34 +82,34 @@ def validate_webservice_is_whitelisted account: account, enabled_authenticators: @enabled_authenticators ) - @logger.debug(LogMessages::Authentication::AuthnJwt::ValidatedStatusWebserviceIsWhitelisted.new) + @logger.debug{LogMessages::Authentication::AuthnJwt::ValidatedStatusWebserviceIsWhitelisted.new} end def validate_issuer @fetch_issuer_value.call(authenticator_input: authenticator_input) - @logger.debug(LogMessages::Authentication::AuthnJwt::ValidatedIssuerConfiguration.new) + @logger.debug{LogMessages::Authentication::AuthnJwt::ValidatedIssuerConfiguration.new} end def validate_audience @fetch_audience_value.call(authenticator_input: authenticator_input) - @logger.debug(LogMessages::Authentication::AuthnJwt::ValidatedAudienceConfiguration.new) + @logger.debug{LogMessages::Authentication::AuthnJwt::ValidatedAudienceConfiguration.new} end def validate_enforced_claims @fetch_enforced_claims.call(jwt_authenticator_input: jwt_authenticator_input) - @logger.debug(LogMessages::Authentication::AuthnJwt::ValidatedEnforcedClaimsConfiguration.new) + @logger.debug{LogMessages::Authentication::AuthnJwt::ValidatedEnforcedClaimsConfiguration.new} end def validate_claim_aliases @fetch_claim_aliases.call(jwt_authenticator_input: jwt_authenticator_input) - @logger.debug(LogMessages::Authentication::AuthnJwt::ValidatedClaimAliasesConfiguration.new) + @logger.debug{LogMessages::Authentication::AuthnJwt::ValidatedClaimAliasesConfiguration.new} end def validate_identity_secrets @validate_identity_configured_properly.call( jwt_authenticator_input: jwt_authenticator_input ) - @logger.debug(LogMessages::Authentication::AuthnJwt::ValidatedIdentityConfiguration.new) + @logger.debug{LogMessages::Authentication::AuthnJwt::ValidatedIdentityConfiguration.new} end def jwt_authenticator_input @@ -143,7 +143,7 @@ def validate_signing_key signing_key_provider.call( force_fetch: false ) - @logger.debug(LogMessages::Authentication::AuthnJwt::ValidatedSigningKeyConfiguration.new) + @logger.debug{LogMessages::Authentication::AuthnJwt::ValidatedSigningKeyConfiguration.new} end def signing_key_provider diff --git a/app/domain/authentication/authn_jwt/vendor_configurations/configuration_jwt_generic_vendor.rb b/app/domain/authentication/authn_jwt/vendor_configurations/configuration_jwt_generic_vendor.rb index 32dc84f9f7..5fb2360b75 100644 --- a/app/domain/authentication/authn_jwt/vendor_configurations/configuration_jwt_generic_vendor.rb +++ b/app/domain/authentication/authn_jwt/vendor_configurations/configuration_jwt_generic_vendor.rb @@ -67,7 +67,7 @@ def validate_and_decode_token authenticator_input: @authenticator_input, jwt_token: jwt_token ) - @logger.debug(LogMessages::Authentication::AuthnJwt::CreatingJWTAuthenticationInputObject.new) + @logger.debug{LogMessages::Authentication::AuthnJwt::CreatingJWTAuthenticationInputObject.new} @jwt_authenticator_input = @jwt_authenticator_input_class.new( authenticator_input: @authenticator_input, decoded_token: decoded_token @@ -116,9 +116,9 @@ def constraints end def validate_resource_restrictions - @logger.debug(LogMessages::Authentication::AuthnJwt::CreateJwtRestrictionsValidatorInstance.new) + @logger.debug{LogMessages::Authentication::AuthnJwt::CreateJwtRestrictionsValidatorInstance.new} @validate_resource_restrictions ||= @validate_resource_restrictions_class.new(extract_resource_restrictions: extract_resource_restrictions) - @logger.debug(LogMessages::Authentication::AuthnJwt::CreatedJwtRestrictionsValidatorInstance.new) + @logger.debug{LogMessages::Authentication::AuthnJwt::CreatedJwtRestrictionsValidatorInstance.new} @validate_resource_restrictions end end diff --git a/app/domain/authentication/authn_k8s/TROUBLESHOOTING.md b/app/domain/authentication/authn_k8s/TROUBLESHOOTING.md index 902089f0e3..9a997fd02f 100644 --- a/app/domain/authentication/authn_k8s/TROUBLESHOOTING.md +++ b/app/domain/authentication/authn_k8s/TROUBLESHOOTING.md @@ -124,7 +124,7 @@ not to install a software load balancer such as CONJUR_NAMESPACE=conjur-oss # Create a Conjur CLI pod in the Conjur Open Source namespace - CLI_IMAGE=cyberark/conjur-cli:5-latest + CLI_IMAGE=cyberark/conjur-cli:8 echo " --- apiVersion: apps/v1 @@ -166,8 +166,8 @@ not to install a software load balancer such as export CLI_POD="$(kubectl get pods -n $CONJUR_NAMESPACE -l app=conjur-cli \ -o jsonpath='{.items[0].metadata.name}')" CONJUR_URL="https://conjur-oss.$CONJUR_NAMESPACE.svc.cluster.local" - kubectl exec -n $CONJUR_NAMESPACE $CLI_POD -- bash -c "yes yes | conjur init -a $CONJUR_ACCOUNT -u $CONJUR_URL" - kubectl exec -n $CONJUR_NAMESPACE $CLI_POD -- conjur authn login -u admin -p $ADMIN_PASSWORD + kubectl exec -n $CONJUR_NAMESPACE $CLI_POD -- bash -c "yes yes | conjur init --account $CONJUR_ACCOUNT --url $CONJUR_URL" + kubectl exec -n $CONJUR_NAMESPACE $CLI_POD -- conjur login --id admin --password $ADMIN_PASSWORD # Create a 'conjur' command alias alias conjur="kubectl exec -n conjur-oss $CLI_POD -- conjur" @@ -864,7 +864,7 @@ of your Conjur authentication configuration. CONJUR_ACCOUNT="myConjurAccount" HOST_RESOURCE="$CONJUR_ACCOUNT:$(echo $CONJUR_AUTHN_LOGIN | sed 's/\//:/')" - conjur show $HOST_RESOURCE + conjur resource show $HOST_RESOURCE ``` @@ -875,7 +875,7 @@ of your Conjur authentication configuration. Click to see example Conjur authentication host definition. ``` - $ conjur show $HOST_RESOURCE + $ conjur resource show $HOST_RESOURCE { "created_at": "2020-11-05T20:43:20.039+00:00", "id": "myConjurAccount:host:conjur/authn-k8s/my-authenticator-id/apps/test-app-secretless", @@ -944,7 +944,7 @@ of your Conjur authentication configuration. e.g.: ```sh-session - $ conjur list -k webservice -s $AUTHENTICATOR_ID + $ conjur list --kind webservice --search $AUTHENTICATOR_ID [ "myConjurAccount:webservice:conjur/authn-k8s/my-authenticator-id" ] @@ -955,7 +955,7 @@ of your Conjur authentication configuration. Webserver resource. For example: ```sh-session - $ conjur show myConjurAccount:webservice:conjur/authn-k8s/my-authenticator-id + $ conjur role show myConjurAccount:webservice:conjur/authn-k8s/my-authenticator-id { "created_at": "2020-11-05T20:43:20.884+00:00", "id": "myConjurAccount:webservice:conjur/authn-k8s/my-authenticator-id", @@ -996,16 +996,16 @@ of your Conjur authentication configuration. ``` # Example: List all hosts associated with a Kubernetes authenticator ID - conjur list -k host -s my-authenticator-id + conjur list --kind host --search my-authenticator-id # Example: Show a host definition - conjur show myConjurAccount:host:conjur/authn-k8s/my-authenticator-id/apps/test-app-secretless + conjur role show myConjurAccount:host:conjur/authn-k8s/my-authenticator-id/apps/test-app-secretless # Example: Show members of a layer - conjur role members myConjurAccount:layer:conjur/authn-k8s/my-authenticator-id/apps + conjur role members myConjurAccount:layer:conjur/authn-k8s/my-authenticator-id/apps # Example: List Webservices associated with a Kubernetes authenticator ID, with details - conjur list -k webservice -s my-authenticator-id --inspect + conjur list --kind webservice --search my-authenticator-id --inspect ``` ### Failure Conditions and How to Troubleshoot diff --git a/app/domain/authentication/authn_k8s/authenticator.rb b/app/domain/authentication/authn_k8s/authenticator.rb index d776d2950a..d45189608f 100644 --- a/app/domain/authentication/authn_k8s/authenticator.rb +++ b/app/domain/authentication/authn_k8s/authenticator.rb @@ -117,6 +117,13 @@ class Authenticator def valid?(input) call(authenticator_input: input) end + + def status(authenticator_status_input:) + Authentication::AuthnK8s::ValidateStatus.new.call( + account: authenticator_status_input.account, + service_id: authenticator_status_input.service_id + ) + end end end end diff --git a/app/domain/authentication/authn_k8s/consts.rb b/app/domain/authentication/authn_k8s/consts.rb index 0a8f1800b5..7c39efab7e 100644 --- a/app/domain/authentication/authn_k8s/consts.rb +++ b/app/domain/authentication/authn_k8s/consts.rb @@ -4,6 +4,18 @@ module Authentication module AuthnK8s AUTHENTICATOR_NAME = 'authn-k8s' + REQUIRED_VARIABLE_NAMES = %w[ + ca/cert + ca/key + ].freeze + + # These are optional because they may be provided as environment variables + # instead of as Conjur variables. + OPTIONAL_VARIABLE_NAMES = %w[ + kubernetes/ca-cert + kubernetes/api-url + kubernetes/service-account-token + ].freeze module Restrictions diff --git a/app/domain/authentication/authn_k8s/execute_command_in_container.rb b/app/domain/authentication/authn_k8s/execute_command_in_container.rb index 3452a2afaf..a5351f3f34 100644 --- a/app/domain/authentication/authn_k8s/execute_command_in_container.rb +++ b/app/domain/authentication/authn_k8s/execute_command_in_container.rb @@ -76,7 +76,7 @@ def add_websocket_event_handlers # Add log tags (origin, thread id, etc.) to sub-thread as they are not # passed automatically. We append the sub-thread id to the main one so # we can easily know the flow from the logs and connect between the threads - tid = syscall(186) + tid = Thread.current.native_thread_id sub_thread_tags = main_thread_tags.map do |x| x.start_with?("tid=") ? "#{x}=>#{tid}" : x end diff --git a/app/domain/authentication/authn_k8s/extract_container_name.rb b/app/domain/authentication/authn_k8s/extract_container_name.rb index 01439b15bc..7be05cf4f7 100644 --- a/app/domain/authentication/authn_k8s/extract_container_name.rb +++ b/app/domain/authentication/authn_k8s/extract_container_name.rb @@ -32,18 +32,18 @@ def annotation_value name # return the value of the annotation if it exists, nil otherwise if annotation - @logger.debug(LogMessages::Authentication::ResourceRestrictions::RetrievedAnnotationValue.new(name)) + @logger.debug{LogMessages::Authentication::ResourceRestrictions::RetrievedAnnotationValue.new(name)} annotation[:value] end end def default_authentication_container_name - @logger.debug( + @logger.debug{ LogMessages::Authentication::ContainerNameAnnotationDefaultValue.new( Restrictions::AUTHENTICATION_CONTAINER_NAME, DEFAULT_AUTHENTICATION_CONTAINER_NAME ) - ) + } DEFAULT_AUTHENTICATION_CONTAINER_NAME end diff --git a/app/domain/authentication/authn_k8s/extract_k8s_resource_restrictions.rb b/app/domain/authentication/authn_k8s/extract_k8s_resource_restrictions.rb index d57118b766..f908ad7b4b 100644 --- a/app/domain/authentication/authn_k8s/extract_k8s_resource_restrictions.rb +++ b/app/domain/authentication/authn_k8s/extract_k8s_resource_restrictions.rb @@ -33,11 +33,11 @@ def extract_resource_restrictions resource_restrictions = restrictions_from_annotations.except(Restrictions::AUTHENTICATION_CONTAINER_NAME) return resource_restrictions if resource_restrictions.any? - @logger.debug(LogMessages::Authentication::AuthnK8s::ExtractingRestrictionsFromHostId.new(@role_name)) + @logger.debug{LogMessages::Authentication::AuthnK8s::ExtractingRestrictionsFromHostId.new(@role_name)} resource_restrictions = resource_restrictions_from_host_id - @logger.debug(LogMessages::Authentication::ResourceRestrictions::ExtractedResourceRestrictions.new(resource_restrictions.names)) + @logger.debug{LogMessages::Authentication::ResourceRestrictions::ExtractedResourceRestrictions.new(resource_restrictions.names)} resource_restrictions end diff --git a/app/domain/authentication/authn_k8s/inject_client_cert.rb b/app/domain/authentication/authn_k8s/inject_client_cert.rb index 954e911fc4..50171ca8ed 100644 --- a/app/domain/authentication/authn_k8s/inject_client_cert.rb +++ b/app/domain/authentication/authn_k8s/inject_client_cert.rb @@ -38,11 +38,11 @@ def call # that happens in the "authenticate" request will work, as the signed certificate # contains the full host-id. def update_csr_common_name - @logger.debug( + @logger.debug{ LogMessages::Authentication::AuthnK8s::SetCommonName.new( full_host_name ) - ) + } smart_csr.common_name = full_host_name end @@ -69,14 +69,14 @@ def install_signed_cert pod_namespace = spiffe_id.namespace pod_name = spiffe_id.name cert_file_path = "/etc/conjur/ssl/client.pem" - @logger.debug( + @logger.debug{ LogMessages::Authentication::AuthnK8s::CopySSLToPod.new( container_name, cert_file_path, pod_namespace, pod_name ) - ) + } @copy_text_to_file_in_container.call( webservice: webservice, @@ -88,7 +88,7 @@ def install_signed_cert mode: "644" ) - @logger.debug(LogMessages::Authentication::AuthnK8s::InitializeCopySSLToPodSuccess.new) + @logger.debug{LogMessages::Authentication::AuthnK8s::InitializeCopySSLToPodSuccess.new} end def pod_request diff --git a/app/domain/authentication/authn_k8s/k8s_host.rb b/app/domain/authentication/authn_k8s/k8s_host.rb index e422a2f35f..c4690c4c3b 100644 --- a/app/domain/authentication/authn_k8s/k8s_host.rb +++ b/app/domain/authentication/authn_k8s/k8s_host.rb @@ -50,9 +50,9 @@ def initialize(account:, service_name:, common_name:) def conjur_host_id host_id = "#{@account}:" + @common_name.k8s_host_name.sub('host/', 'host:') - Rails.logger.debug( + Rails.logger.debug{ LogMessages::Authentication::AuthnK8s::HostIdFromCommonName.new(host_id) - ) + } host_id end diff --git a/app/domain/authentication/authn_k8s/k8s_object_lookup.rb b/app/domain/authentication/authn_k8s/k8s_object_lookup.rb index 06262ec4c3..9708e50e7c 100644 --- a/app/domain/authentication/authn_k8s/k8s_object_lookup.rb +++ b/app/domain/authentication/authn_k8s/k8s_object_lookup.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# K8sObjectLookup is used to lookup Kubernetes object metadata using +# K8sObjectLookup is used to lookup Kubernetes object metadata using # Kubernetes API. This is essentially a facade over the API # module Authentication @@ -28,7 +28,10 @@ def initialize(webservice = nil) return unless ENV.key?('SSL_CERT_DIRECTORY') - load_additional_certs(ENV['SSL_CERT_DIRECTORY']) + ::Conjur::CertUtils.load_certificates( + @cert_store, + File.join(ENV['SSL_CERT_DIRECTORY'], 'ca') + ) end def bearer_token @@ -36,7 +39,7 @@ def bearer_token @webservice, SERVICEACCOUNT_TOKEN_PATH, VARIABLE_BEARER_TOKEN - ) + )&.strip end def ca_cert @@ -44,7 +47,7 @@ def ca_cert @webservice, SERVICEACCOUNT_CA_PATH, VARIABLE_CA_CERT - ) + )&.strip raise Errors::Authentication::AuthnK8s::MissingCertificate if cert.blank? @@ -60,7 +63,7 @@ def options cert_store: @cert_store, verify_ssl: OpenSSL::SSL::VERIFY_PEER }, - http_proxy_uri: ENV['https_proxy'] || ENV['http_proxy'] + http_proxy_uri: URI.parse(api_url).find_proxy } end @@ -201,7 +204,6 @@ def k8s_clients KubeClientFactory.client( api: 'apis/apps.openshift.io', version: 'v1', host_url: api_url, options: options - ) ] end @@ -215,16 +217,6 @@ def handle_object_not_found &block raise unless $!.error_code == 404 end end - - def load_additional_certs(ssl_cert_directory) - # allows us to add additional CA certs for things like SNI certs - if Dir.exist?("#{ssl_cert_directory}/ca") - Dir["#{ssl_cert_directory}/ca/*"].each do |file_name| - ::Conjur::CertUtils.add_chained_cert(@cert_store, File.read(file_name)) if - File.exist?(file_name) - end - end - end end end end diff --git a/app/domain/authentication/authn_k8s/k8s_resource_validator.rb b/app/domain/authentication/authn_k8s/k8s_resource_validator.rb index 31b5105924..6cb85adeeb 100644 --- a/app/domain/authentication/authn_k8s/k8s_resource_validator.rb +++ b/app/domain/authentication/authn_k8s/k8s_resource_validator.rb @@ -16,18 +16,18 @@ def initialize(k8s_object_lookup:, pod:, logger: Rails.logger) end def valid_resource?(type:, name:) - @logger.debug(LogMessages::Authentication::AuthnK8s::ValidatingK8sResource.new(type, name)) + @logger.debug{LogMessages::Authentication::AuthnK8s::ValidatingK8sResource.new(type, name)} k8s_resource = retrieve_k8s_resource(type, name) validate(k8s_resource, type, name) - @logger.debug(LogMessages::Authentication::AuthnK8s::ValidatedK8sResource.new(type, name)) + @logger.debug{LogMessages::Authentication::AuthnK8s::ValidatedK8sResource.new(type, name)} end # Validates label selector and creates a hash # In the spirit of https://github.com/kubernetes/apimachinery/blob/master/pkg/labels/selector.go def valid_namespace?(label_selector:) - @logger.debug(LogMessages::Authentication::AuthnK8s::ValidatingK8sResourceLabel.new('namespace', namespace, label_selector)) + @logger.debug{LogMessages::Authentication::AuthnK8s::ValidatingK8sResourceLabel.new('namespace', namespace, label_selector)} if label_selector.length == 0 raise Errors::Authentication::AuthnK8s::InvalidLabelSelector.new(label_selector) @@ -58,7 +58,7 @@ def valid_namespace?(label_selector:) raise Errors::Authentication::AuthnK8s::LabelSelectorMismatch.new('namespace', namespace, label_selector) end - @logger.debug(LogMessages::Authentication::AuthnK8s::ValidatedK8sResourceLabel.new('namespace', namespace, label_selector)) + @logger.debug{LogMessages::Authentication::AuthnK8s::ValidatedK8sResourceLabel.new('namespace', namespace, label_selector)} return true end diff --git a/app/domain/authentication/authn_k8s/proxied_tcp_socket.rb b/app/domain/authentication/authn_k8s/proxied_tcp_socket.rb index 00036add99..1ba5532656 100644 --- a/app/domain/authentication/authn_k8s/proxied_tcp_socket.rb +++ b/app/domain/authentication/authn_k8s/proxied_tcp_socket.rb @@ -39,10 +39,10 @@ def initialize( def connect_proxy_socket # We log the proxy host and port specifically because the full URI may # contain authorization fields. - @logger.debug( + @logger.debug{ "Connecting to '#{@destination_host}:#{@destination_port}' " \ "through proxy server: '#{@proxy_uri.host}:#{@proxy_uri.port}'" - ) + } @proxy_socket = TCPSocket.new( @proxy_uri.host, diff --git a/app/domain/authentication/authn_k8s/validate_status.rb b/app/domain/authentication/authn_k8s/validate_status.rb new file mode 100644 index 0000000000..caf29550b0 --- /dev/null +++ b/app/domain/authentication/authn_k8s/validate_status.rb @@ -0,0 +1,309 @@ +# frozen_string_literal: true + +module Authentication + module AuthnK8s + # AuthnK8s::ValidateStatus raises an exception if the Kubernetes + # authenticator is not configured properly or with inadequate permissions. + ValidateStatus = CommandClass.new( + dependencies: { + fetch_authenticator_secrets: Authentication::Util::FetchAuthenticatorSecrets.new( + optional_variable_names: OPTIONAL_VARIABLE_NAMES + ) + }, + inputs: %i[account service_id] + ) do + def call + validate_configuration_values + end + + protected + + def validate_configuration_values + # Validate configuration values for k8s API access + validate_k8s_service_account_token + validate_k8s_ca_certificate + validate_k8s_api_url + + # Validate configuration values for issuing authentication certificates. + # --- + # The certificate validation uses the private key, so we validate the + # private key first. + validate_conjur_ca_private_key + validate_conjur_ca_certificate + + # Validate Kubernetes API access and authorization + validate_k8s_api_access + end + + def validate_k8s_service_account_token + k8s_service_account_token + rescue JWT::DecodeError => e + raise Errors::Authentication::AuthnK8s::InvalidServiceAccountToken, + "Unable to decode JWT: #{e.message}" + end + + def validate_k8s_ca_certificate + # Ensure that none of the provided certificates have expired + k8s_ca_certificate.each do |certificate| + next unless certificate.not_after < Time.now + + raise( + Errors::Authentication::AuthnK8s::InvalidApiCert, + "Certificate has expired: #{certificate.subject}" + ) + end + rescue OpenSSL::X509::CertificateError => e + raise( + Errors::Authentication::AuthnK8s::InvalidApiCert, + "Unable to read certificate: #{e.message}" + ) + end + + def validate_k8s_api_url + # URI#parse will gracefully handle an empty string, so we check for that + # specifically here. + return unless k8s_api_url.to_s == '' + + raise Errors::Authentication::AuthnK8s::InvalidApiUrl, k8s_api_url + rescue URI::InvalidURIError + raise Errors::Authentication::AuthnK8s::InvalidApiUrl, \ + authenticator_secrets['kubernetes/api-url'] + end + + def validate_conjur_ca_certificate + # Is the certificate expired? + if conjur_ca_certificate.not_after < Time.now + raise( + Errors::Authentication::AuthnK8s::InvalidSigningCert, + "Certificate has expired" + ) + end + + # Does the certificate have the correct attributes for certificate signing? + # basicConstraints should include CA:TRUE + basic_constraints_present = conjur_ca_certificate + .extensions + .find do |ext| + ext.oid == 'basicConstraints' && ext.value.include?('CA:TRUE') + end + unless basic_constraints_present + raise( + Errors::Authentication::AuthnK8s::InvalidSigningCert, + "Certificate does not include basicConstraints attribute: CA:TRUE" + ) + end + + # keyUsage should include Certificate Sign. This comes by setting the + # value 'keyCertSign' when creating the certificate + key_cert_sign_present = conjur_ca_certificate + .extensions + .find do |ext| + ext.oid == 'keyUsage' && ext.value.include?('Certificate Sign') + end + unless key_cert_sign_present + raise( + Errors::Authentication::AuthnK8s::InvalidSigningCert, + "Certificate does not include keyUsage attribute: 'Certificate Sign'" + ) + end + + # Is the certificate valid with the private key? + keys_match = conjur_ca_certificate.public_key.to_s == \ + conjur_ca_private_key.public_key.to_s + unless keys_match + raise( + Errors::Authentication::AuthnK8s::InvalidSigningCert, + "Certificate and private key do not match" + ) + end + rescue OpenSSL::X509::CertificateError => e + raise Errors::Authentication::AuthnK8s::InvalidSigningCert, + "Unable to read certificate: #{e.message}" + end + + def validate_conjur_ca_private_key + conjur_ca_private_key + rescue OpenSSL::PKey::RSAError + # We don't bubble up the internal error message to avoid leaking any + # accidental information about the private key. + raise Errors::Authentication::AuthnK8s::InvalidSigningKey, + "Unable to read private key" + end + + def validate_k8s_api_access + # To check our access token, we'll attempt to retrieve the base API + # discovery URI. If we get a "401 Unauthorized response", then it + # means the access token is invalid. + # + # If the response is "403 Forbidden", then it means the access token + # is valid for authentication, but the service account is missing + # authorization to perform API discovery. This requires the + # 'system:discovery' role to be bound to the service account. See: + # https://kubernetes.io/docs/reference/access-authn-authz/rbac/#discovery-roles + # + # Note, it is possible to configure Kubernetes such that API discovery + # is publicly accessible, but that's not the default configuration and + # isn't a configuration change we expect or support validating. In that + # case, accessing the API discovery may not indicate the service account + # token is valid. + url = "#{k8s_api_url}/apis" + + RestClient.proxy = k8s_api_url.find_proxy + + headers = { + Authorization: "Bearer #{k8s_service_account_token_input}", + Accept: 'application/json' + } + RestClient::Request.execute( + method: :get, + url: url, + headers: headers, + ssl_cert_store: k8s_cert_store + ) + rescue RestClient::Unauthorized => e + raise( + Errors::Authentication::AuthnK8s::InvalidServiceAccountToken, + e.message + ) + rescue RestClient::Forbidden => e + raise( + Errors::Authentication::AuthnK8s::InvalidServiceAccountToken, + "Service account is unauthorized to perform API discovery: " \ + "#{e.message}. Ensure the 'system:discovery' role is bound to " \ + "service account" + ) + end + + private + + def authenticator_secrets + @authenticator_secrets ||= @fetch_authenticator_secrets.call( + service_id: @service_id, + conjur_account: @account, + authenticator_name: AUTHENTICATOR_NAME, + required_variable_names: REQUIRED_VARIABLE_NAMES + ) + end + + def k8s_service_account_token + # Ensure there are no invalid characters in the service account token + # + # This array must use double-quotes to ensure it's the special character + # that we're checking for. + invalid_chars = ["\n", "\r"] + invalid_chars_present = invalid_chars.select do |char| + k8s_service_account_token_input.include?(char) + end + + if invalid_chars_present.any? + raise( + Errors::Authentication::AuthnK8s::InvalidServiceAccountToken, + "Invalid characters in token: " \ + "#{invalid_chars_present.join(', ')}" + ) + end + + @k8s_service_account_token ||= \ + JWT.decode(k8s_service_account_token_input, nil, false) + end + + def k8s_service_account_token_input + # First check if we're using a service account token file (i.e when + # we're running inside of Kubernetes) + if File.exist?(SERVICEACCOUNT_TOKEN_PATH) + return File.read(SERVICEACCOUNT_TOKEN_PATH) + end + + # Because this variable is optional, it's possible for it to be nil and + # we need to handle that case. + authenticator_secrets['kubernetes/service-account-token'] || \ + raise( + Errors::Conjur::RequiredResourceMissing, + 'kubernetes/service-account-token' + ) + end + + def k8s_ca_certificate + # This value may contain multiple certificates or certificate chains + @k8s_ca_certificate ||= ::Conjur::CertUtils.parse_certs( + k8s_ca_certificate_input + ) + end + + def k8s_ca_certificate_input + # First check if we're using a CA bundle file (i.e when we're running + # inside of Kubernetes) + if File.exist?(SERVICEACCOUNT_CA_PATH) + return File.read(SERVICEACCOUNT_CA_PATH) + end + + # Because this variable is optional, it's possible for it to be nil and + # we need to handle that case. + authenticator_secrets['kubernetes/ca-cert'] || \ + raise(Errors::Conjur::RequiredResourceMissing, 'kubernetes/ca-cert') + end + + def k8s_api_url + @k8s_api_url ||= URI.parse(k8s_api_url_input) + end + + def k8s_api_url_input + # The API URL may come from environment variables, if they're present + host = ENV['KUBERNETES_SERVICE_HOST'] + port = ENV['KUBERNETES_SERVICE_PORT'] + + if host.present? && port.present? + return "https://#{host}:#{port}" + end + + # Because this variable is optional, it's possible for it to be nil and + # we need to handle that case. + authenticator_secrets['kubernetes/api-url'] || \ + raise(Errors::Conjur::RequiredResourceMissing, 'kubernetes/api-url') + end + + def conjur_ca_certificate + @conjur_ca_certificate ||= begin + certs = ::Conjur::CertUtils.parse_certs( + authenticator_secrets['ca/cert'] + ) + + # Should only be one + if certs.length > 1 + raise( + Errors::Authentication::AuthnK8s::InvalidSigningCert, + "Value contains multiple certificates. " \ + "Only a single signing certificate allowed" + ) + end + + certs.first + end + end + + def conjur_ca_private_key + @conjur_ca_private_key ||= \ + OpenSSL::PKey::RSA.new( + authenticator_secrets['ca/key'] + ) + end + + def k8s_cert_store + @k8s_cert_store ||= OpenSSL::X509::Store.new.tap do |store| + store.set_default_paths + + k8s_ca_certificate.each do |cert| + store.add_cert(cert) + end + + if ENV.key?('SSL_CERT_DIRECTORY') + ::Conjur::CertUtils.load_certificates( + store, + File.join(ENV['SSL_CERT_DIRECTORY'], 'ca') + ) + end + end + end + end + end +end diff --git a/app/domain/authentication/authn_k8s/web_socket_client.rb b/app/domain/authentication/authn_k8s/web_socket_client.rb index 078f4df877..d251d53e37 100644 --- a/app/domain/authentication/authn_k8s/web_socket_client.rb +++ b/app/domain/authentication/authn_k8s/web_socket_client.rb @@ -134,17 +134,7 @@ def secure_socket # environment. If the server connection uses TLS, then use the # https_proxy value, otherwise use the http_proxy value. def proxy_uri - @proxy_uri ||= begin - proxy_url = if secure? - ENV['https_proxy'] || ENV['HTTPS_PROXY'] - else - ENV['http_proxy'] || ENV['HTTP_PROXY'] - end - - URI.parse(proxy_url) - rescue URI::InvalidURIError - nil - end + @proxy_uri ||= @uri.find_proxy end def secure? diff --git a/app/domain/authentication/authn_k8s/web_socket_client_event_handler.rb b/app/domain/authentication/authn_k8s/web_socket_client_event_handler.rb index 47ae3b918a..5da4d5e53e 100644 --- a/app/domain/authentication/authn_k8s/web_socket_client_event_handler.rb +++ b/app/domain/authentication/authn_k8s/web_socket_client_event_handler.rb @@ -33,9 +33,9 @@ def on_open raise Errors::Authentication::AuthnK8s::WebSocketHandshakeError, handshake_error.inspect end - @logger.debug( + @logger.debug{ LogMessages::Authentication::AuthnK8s::PodChannelOpen.new(@pod_name) - ) + } return unless @stdin @@ -59,27 +59,27 @@ def on_message(msg) case msg_type when :binary - @logger.debug( + @logger.debug{ LogMessages::Authentication::AuthnK8s::PodChannelData.new( @pod_name, ws_msg.channel_name, msg_data ) - ) + } @validate_message.call(ws_msg) @message_log.save_message(ws_msg) when :close - @logger.debug( + @logger.debug{ LogMessages::Authentication::AuthnK8s::PodMessageData.new( @pod_name, "close", msg_data ) - ) + } @ws_client.close end end def on_close - @logger.debug( + @logger.debug{ LogMessages::Authentication::AuthnK8s::PodChannelClosed.new(@pod_name) - ) + } # The value itself doesn't matter, so we just use nil @close_event_queue << nil @@ -87,9 +87,9 @@ def on_close def on_error(err) error_info = err.inspect - @logger.debug( + @logger.debug{ LogMessages::Authentication::AuthnK8s::PodError.new(@pod_name, error_info) - ) + } @message_log.save_error_string(error_info) # The value itself doesn't matter, so we just use nil diff --git a/app/domain/authentication/authn_oidc/authenticator.rb b/app/domain/authentication/authn_oidc/authenticator.rb index 9f765b6ee4..45201b2d45 100644 --- a/app/domain/authentication/authn_oidc/authenticator.rb +++ b/app/domain/authentication/authn_oidc/authenticator.rb @@ -28,28 +28,28 @@ def status(authenticator_status_input:) # term, we need to port the V1 functionality to V2. Once that # is done, the following check can be removed. - # Attempt to load the V2 version of the OIDC Authenticator - authenticator = DB::Repository::AuthenticatorRepository.new( - data_object: Authentication::AuthnOidc::V2::DataObjects::Authenticator - ).find( + DB::Repository::AuthenticatorRepository.new.find( type: authenticator_status_input.authenticator_name, account: authenticator_status_input.account, service_id: authenticator_status_input.service_id - ) - # If successful, validate the new set of required variables - if authenticator.present? - Authentication::AuthnOidc::ValidateStatus.new( - required_variable_names: %w[provider-uri client-id client-secret claim-mapping] - ).( - account: authenticator_status_input.account, - service_id: authenticator_status_input.service_id - ) - else - # Otherwise, perform the default check - Authentication::AuthnOidc::ValidateStatus.new.( - account: authenticator_status_input.account, - service_id: authenticator_status_input.service_id - ) + ).bind do |authenticator_data| + # check if this authenticator appears to be a Code Redirect authenticator + if authenticator_data.key?(:client_id) + Authentication::AuthnOidc::ValidateStatus.new( + required_variable_names: %w[provider-uri client-id client-secret claim-mapping], + optional_variable_names: %w[ca-cert] + ).( + account: authenticator_status_input.account, + service_id: authenticator_status_input.service_id + ) + + # Otherwise, use the old style check + else + Authentication::AuthnOidc::ValidateStatus.new.( + account: authenticator_status_input.account, + service_id: authenticator_status_input.service_id + ) + end end end end diff --git a/app/domain/authentication/authn_oidc/update_input_with_username_from_id_token.rb b/app/domain/authentication/authn_oidc/update_input_with_username_from_id_token.rb index c5cc648845..76c36d8c99 100644 --- a/app/domain/authentication/authn_oidc/update_input_with_username_from_id_token.rb +++ b/app/domain/authentication/authn_oidc/update_input_with_username_from_id_token.rb @@ -3,7 +3,6 @@ module AuthnOidc UpdateInputWithUsernameFromIdToken ||= CommandClass.new( dependencies: { - fetch_authenticator_secrets: Authentication::Util::FetchAuthenticatorSecrets.new, validate_account_exists: ::Authentication::Security::ValidateAccountExists.new, verify_and_decode_token: ::Authentication::OAuth::VerifyAndDecodeToken.new, logger: Rails.logger @@ -40,7 +39,8 @@ def verify_and_decode_token @decoded_token = @verify_and_decode_token.( provider_uri: oidc_authenticator_secrets["provider-uri"], token_jwt: decoded_credentials["id_token"], - claims_to_verify: {} # We don't verify any claims + claims_to_verify: {}, # We don't verify any claims + ca_cert: oidc_authenticator_secrets["ca-cert"] ) end @@ -86,7 +86,9 @@ def token_from_body end def oidc_authenticator_secrets - @oidc_authenticator_secrets ||= @fetch_authenticator_secrets.( + @oidc_authenticator_secrets ||= Authentication::Util::FetchAuthenticatorSecrets.new( + optional_variable_names: optional_variable_names + ).( service_id: service_id, conjur_account: account, authenticator_name: authenticator_name, @@ -98,26 +100,32 @@ def required_variable_names @required_variable_names ||= %w[provider-uri id-token-user-property] end + def optional_variable_names + @optional_variable_names ||= %w[ca-cert] + end + def validate_conjur_username - if conjur_username.to_s.empty? - raise Errors::Authentication::AuthnOidc::IdTokenClaimNotFoundOrEmpty, - id_token_username_field + if conjur_username.empty? + raise Errors::Authentication::AuthnOidc::IdTokenClaimNotFoundOrEmpty.new( + id_token_username_field, + "id-token-user-property" + ) end if conjur_username == "admin" raise Errors::Authentication::AdminAuthenticationDenied, authenticator_name end - @logger.debug( + @logger.debug{ LogMessages::Authentication::AuthnOidc::ExtractedUsernameFromIDToken.new( conjur_username, id_token_username_field ) - ) + } end def conjur_username - @decoded_token[id_token_username_field] + @decoded_token[id_token_username_field].to_s.downcase end def id_token_username_field diff --git a/app/domain/authentication/authn_oidc/v2/README.md b/app/domain/authentication/authn_oidc/v2/README.md new file mode 100644 index 0000000000..6cc0b92a9b --- /dev/null +++ b/app/domain/authentication/authn_oidc/v2/README.md @@ -0,0 +1,16 @@ +# Authn-OIDC Authenticator + +## Running Cucumber tests + +OIDC Cucumber tests require copying the KeyCloak certificate into the Cucumber +container. This can be accomplished with the following command: + +```sh +ci/oauth/keycloak/fetch_certificate +``` + +Next, run the Cucumber test(s) as follows: + +```sh +KEYCLOAK_CA_CERT=$(cat /etc/ssl/certs/keycloak.pem) bundle exec cucumber -p authenticators_oidc cucumber/authenticators_oidc/features/authn_oidc_v2.feature:100 +``` diff --git a/app/domain/authentication/authn_oidc/v2/client.rb b/app/domain/authentication/authn_oidc/v2/client.rb deleted file mode 100644 index 80a82c00c2..0000000000 --- a/app/domain/authentication/authn_oidc/v2/client.rb +++ /dev/null @@ -1,116 +0,0 @@ -module Authentication - module AuthnOidc - module V2 - class Client - def initialize( - authenticator:, - client: ::OpenIDConnect::Client, - oidc_id_token: ::OpenIDConnect::ResponseObject::IdToken, - discovery_configuration: ::OpenIDConnect::Discovery::Provider::Config, - cache: Rails.cache, - logger: Rails.logger - ) - @authenticator = authenticator - @client = client - @oidc_id_token = oidc_id_token - @discovery_configuration = discovery_configuration - @cache = cache - @logger = logger - end - - def oidc_client - @oidc_client ||= begin - issuer_uri = URI(@authenticator.provider_uri) - @client.new( - identifier: @authenticator.client_id, - secret: @authenticator.client_secret, - redirect_uri: @authenticator.redirect_uri, - scheme: issuer_uri.scheme, - host: issuer_uri.host, - port: issuer_uri.port, - authorization_endpoint: URI(discovery_information.authorization_endpoint).path, - token_endpoint: URI(discovery_information.token_endpoint).path, - userinfo_endpoint: URI(discovery_information.userinfo_endpoint).path, - jwks_uri: URI(discovery_information.jwks_uri).path - ) - end - end - - def callback(code:, nonce:, code_verifier: nil) - oidc_client.authorization_code = code - access_token_args = { client_auth_method: :basic } - access_token_args[:code_verifier] = code_verifier if code_verifier.present? - begin - bearer_token = oidc_client.access_token!(**access_token_args) - rescue Rack::OAuth2::Client::Error => e - # Only handle the expected errors related to access token retrieval. - case e.message - when /PKCE verification failed/, # Okta's PKCE failure msg - /challenge mismatch/ # Identity's PKCE failure msg - raise Errors::Authentication::AuthnOidc::TokenRetrievalFailed, - 'PKCE verification failed' - when /The authorization code is invalid or has expired/, # Okta's reused code msg - /supplied code does not match known request/ # Identity's reused code msg - raise Errors::Authentication::AuthnOidc::TokenRetrievalFailed, - 'Authorization code is invalid or has expired' - when /Code not valid/ - raise Errors::Authentication::AuthnOidc::TokenRetrievalFailed, - 'Authorization code is invalid' - end - raise e - end - id_token = bearer_token.id_token || bearer_token.access_token - - begin - attempts ||= 0 - decoded_id_token = @oidc_id_token.decode( - id_token, - discovery_information.jwks - ) - rescue StandardError => e - attempts += 1 - raise e if attempts > 1 - - # If the JWKS verification fails, blow away the existing cache and - # try again. This is intended to handle the case where the OIDC certificate - # changes, and we want to cache the new certificate without decode failing. - discovery_information(invalidate: true) - retry - end - - begin - decoded_id_token.verify!( - issuer: @authenticator.provider_uri, - client_id: @authenticator.client_id, - nonce: nonce - ) - rescue OpenIDConnect::ResponseObject::IdToken::InvalidNonce - raise Errors::Authentication::AuthnOidc::TokenVerificationFailed, - 'Provided nonce does not match the nonce in the JWT' - rescue OpenIDConnect::ResponseObject::IdToken::ExpiredToken - raise Errors::Authentication::AuthnOidc::TokenVerificationFailed, - 'JWT has expired' - rescue OpenIDConnect::ValidationFailed => e - raise Errors::Authentication::AuthnOidc::TokenVerificationFailed, - e.message - end - decoded_id_token - end - - def discovery_information(invalidate: false) - @cache.fetch( - "#{@authenticator.account}/#{@authenticator.service_id}/#{URI::Parser.new.escape(@authenticator.provider_uri)}", - force: invalidate, - skip_nil: true - ) do - @discovery_configuration.discover!(@authenticator.provider_uri) - rescue HTTPClient::ConnectTimeoutError, Errno::ETIMEDOUT => e - raise Errors::Authentication::OAuth::ProviderDiscoveryTimeout.new(@authenticator.provider_uri, e.message) - rescue => e - raise Errors::Authentication::OAuth::ProviderDiscoveryFailed.new(@authenticator.provider_uri, e.message) - end - end - end - end - end -end diff --git a/app/domain/authentication/authn_oidc/v2/data_objects/authenticator.rb b/app/domain/authentication/authn_oidc/v2/data_objects/authenticator.rb index 15f4bdffe5..31db589c8b 100644 --- a/app/domain/authentication/authn_oidc/v2/data_objects/authenticator.rb +++ b/app/domain/authentication/authn_oidc/v2/data_objects/authenticator.rb @@ -2,10 +2,9 @@ module Authentication module AuthnOidc module V2 module DataObjects - class Authenticator + class Authenticator < Authentication::Base::DataObject - REQUIRED_VARIABLES = %i[provider_uri client_id client_secret claim_mapping].freeze - OPTIONAL_VARIABLES = %i[redirect_uri response_type provider_scope name token_ttl].freeze + # REQUIRES_ROLE_ANNOTIONS = false attr_reader( :provider_uri, @@ -15,9 +14,11 @@ class Authenticator :account, :service_id, :redirect_uri, - :response_type + :response_type, + :ca_cert ) + # rubocop:disable Metrics/ParameterLists def initialize( provider_uri:, client_id:, @@ -29,20 +30,25 @@ def initialize( name: nil, response_type: 'code', provider_scope: nil, - token_ttl: 'PT60M' + token_ttl: '', + ca_cert: nil ) - @account = account + super(account: account, service_id: service_id) + @provider_uri = provider_uri @client_id = client_id @client_secret = client_secret @claim_mapping = claim_mapping @response_type = response_type - @service_id = service_id @name = name @provider_scope = provider_scope @redirect_uri = redirect_uri - @token_ttl = token_ttl + @ca_cert = ca_cert + + # Set TTL to 60 minutes by default + @token_ttl = token_ttl.present? ? token_ttl : 'PT60M' end + # rubocop:enable Metrics/ParameterLists def scope (%w[openid email profile] + [*@provider_scope.to_s.split(' ')]).uniq.join(' ') @@ -51,17 +57,6 @@ def scope def name @name || @service_id.titleize end - - def resource_id - "#{account}:webservice:conjur/authn-oidc/#{service_id}" - end - - # Returns the validity duration, in seconds, of an instance's access tokens. - def token_ttl - ActiveSupport::Duration.parse(@token_ttl) - rescue ActiveSupport::Duration::ISO8601Parser::ParsingError - raise Errors::Authentication::DataObjects::InvalidTokenTTL.new(resource_id, @token_ttl) - end end end end diff --git a/app/domain/authentication/authn_oidc/v2/oidc_client.rb b/app/domain/authentication/authn_oidc/v2/oidc_client.rb new file mode 100644 index 0000000000..91c206ef31 --- /dev/null +++ b/app/domain/authentication/authn_oidc/v2/oidc_client.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +module Authentication + module AuthnOidc + module V2 + class OidcClient + def initialize( + authenticator:, + client: Authentication::Util::NetworkTransporter, + cache: Rails.cache, + logger: Rails.logger + ) + @authenticator = authenticator + @cache = cache + @logger = logger + + @client = client.new( + hostname: @authenticator.provider_uri, + ca_certificate: @authenticator.ca_cert + ) + + @success = ::SuccessResponse + @failure = ::FailureResponse + end + + # 'jwks_uri' - GET + # 'token_endpoint' - POST + # Public method so strategy can call it to verify configuration + def oidc_configuration + @oidc_configuration ||= begin + response = @client.get("#{@authenticator.provider_uri}/.well-known/openid-configuration").bind do |success| + return @success.new(success) + end + @failure.new( + "Authn-OIDC '#{@authenticator.service_id}' provider-uri: '#{@authenticator.provider_uri}' is unreachable", + exception: Errors::Authentication::OAuth::ProviderDiscoveryFailed.new( + @authenticator.provider_uri, + response.message + ), + status: :unauthorized + ) + end + end + + def exchange_code_for_token(code:, nonce:, code_verifier: nil) + args = { + grant_type: 'authorization_code', + scope: true, + code: code, + nonce: nonce + } + args[:code_verifier] = code_verifier if code_verifier.present? + args[:redirect_uri] = ERB::Util.url_encode(@authenticator.redirect_uri) if @authenticator.redirect_uri.present? + + oidc_configuration.bind do |config| + response = @client.post( + path: config['token_endpoint'], + body: args.map { |k, v| "#{k}=#{v}" }.join('&'), + basic_auth: [@authenticator.client_id, @authenticator.client_secret], + headers: { 'Content-Type' => 'application/x-www-form-urlencoded' } + ).bind do |token| + bearer_token = token['id_token'] || token['access_token'] + return @success.new(bearer_token) if bearer_token.present? + + return @failure.new( + 'Bearer Token is empty', + exception: Errors::Authentication::AuthnOidc::TokenRetrievalFailed.new('Bearer Token is empty'), + status: :bad_request + ) + end + @failure.new( + response.message, + exception: Errors::Authentication::AuthnOidc::TokenRetrievalFailed.new(response.message), + status: :bad_request + ) + end + end + end + end + end +end diff --git a/app/domain/authentication/authn_oidc/v2/resolve_identity.rb b/app/domain/authentication/authn_oidc/v2/resolve_identity.rb deleted file mode 100644 index e85cd3a6b0..0000000000 --- a/app/domain/authentication/authn_oidc/v2/resolve_identity.rb +++ /dev/null @@ -1,19 +0,0 @@ -module Authentication - module AuthnOidc - module V2 - class ResolveIdentity - def call(identity:, account:, allowed_roles:) - # make sure role has a resource (ex. user, host) - roles = allowed_roles.select(&:resource?) - - roles.each do |role| - role_account, _, role_id = role.id.split(':') - return role if role_account == account && identity == role_id - end - - raise(Errors::Authentication::Security::RoleNotFound, identity) - end - end - end - end -end diff --git a/app/domain/authentication/authn_oidc/v2/strategy.rb b/app/domain/authentication/authn_oidc/v2/strategy.rb index 4e95e40656..33df7b8cb4 100644 --- a/app/domain/authentication/authn_oidc/v2/strategy.rb +++ b/app/domain/authentication/authn_oidc/v2/strategy.rb @@ -1,41 +1,134 @@ +# frozen_string_literal: true + module Authentication module AuthnOidc module V2 - class Strategy + class Strategy < Authentication::AuthnJwt::V2::Strategy + REQUIRED_PARAMS = %i[code nonce].freeze + ALLOWED_PARAMS = (REQUIRED_PARAMS + %i[code_verifier]).freeze + def initialize( authenticator:, - client: Authentication::AuthnOidc::V2::Client, + oidc_client: OidcClient, + jwt_client: JWT, logger: Rails.logger ) @authenticator = authenticator - @client = client.new(authenticator: authenticator) + @oidc_client = oidc_client.new(authenticator: authenticator) @logger = logger + + @success = ::SuccessResponse + @failure = ::FailureResponse + + # Initialize JWT Strategy which will be used to validate the JWT + # after code exchange. + super( + authenticator: authenticator, + jwt_client: jwt_client, + logger: logger + ) end - def callback(args) - # NOTE: `code_verifier` param is optional - %i[code nonce].each do |param| - unless args[param].present? - raise Errors::Authentication::RequestBody::MissingRequestParam, param.to_s + # rubocop:disable Lint/UnusedMethodArgument + def callback(parameters:, request_body: nil) + validate_parameters(parameters).bind do |params| + nonce = params[:nonce] + exchange_code_for_jwt_token( + code: params[:code], + nonce: nonce, + code_verifier: params[:code_verifier] + ).bind do |bearer_token| + jwks_uri_response = @oidc_client.oidc_configuration.bind do |config| + # `jwks_uri` is manditory in accordance with the OpenID Connect specification: + # https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata + @success.new(config['jwks_uri']) + end + + jwks_uri_response.bind do |jwks_uri| + # This method lives in Authentication::AuthnJwt::V2::Strategy + # Once we have the JWT from the OIDC provider, this is really just a JWT + # authentication exercise. + # + # NOTE: OIDC Authenticator does not validate the `aud` claim. This feels like + # something we should correct... + decode_jwt( + jwt: bearer_token, + issuer: @authenticator.provider_uri, + jwks_uri: jwks_uri + ).bind do |decoded_token| + verify_token(token: decoded_token, nonce: nonce).bind do |verified_token| + identify_role(jwt: verified_token).bind do |identity| + @success.new( + Authentication::RoleIdentifier.new( + identifier: "#{@authenticator.account}:user:#{identity}" + ) + ) + end + end + end + end end end + end + # rubocop:enable Lint/UnusedMethodArgument - identity = resolve_identity( - jwt: @client.callback( - code: args[:code], - nonce: args[:nonce], - code_verifier: args[:code_verifier] - ) - ) - unless identity.present? - raise Errors::Authentication::AuthnOidc::IdTokenClaimNotFoundOrEmpty, - @authenticator.claim_mapping + # TODO: Enable once the status handler has been implemented + # # Called by status handler. This handles checking as much of the strategy + # # integrity as possible without performing an actual authentication. + # def verify_status + # # @client.discover + # @oidc_client.configuration + # end + + private + + def validate_parameters(parameters) + REQUIRED_PARAMS.each do |param| + unless parameters[param].present? + return @failure.new( + "Missing parameter: '#{param}'", + exception: Errors::Authentication::RequestBody::MissingRequestParam.new( + param.to_s + ), + status: :bad_request + ) + end end - identity + @success.new(parameters.select{|item| ALLOWED_PARAMS.include?(item)}) + end + + def exchange_code_for_jwt_token(code:, nonce:, code_verifier:) + @oidc_client.exchange_code_for_token( + code: code, + nonce: nonce, + code_verifier: code_verifier + ) end - def resolve_identity(jwt:) - jwt.raw_attributes.with_indifferent_access[@authenticator.claim_mapping] + def identify_role(jwt:) + identity = jwt[@authenticator.claim_mapping] + return @success.new(identity) if identity.present? + + @failure.new( + "Claim '#{@authenticator.claim_mapping}' was not found in the JWT token", + exception: Errors::Authentication::AuthnOidc::IdTokenClaimNotFoundOrEmpty.new( + @authenticator.claim_mapping, + 'claim-mapping' + ), + status: :unauthorized + ) + end + + def verify_token(token:, nonce:) + unless token['nonce'] == nonce + return @failure.new( + 'Provided nonce does not match the JWT nonce', + exception: Errors::Authentication::AuthnOidc::NonceVerificationFailed.new, + status: :bad_request + ) + end + + @success.new(token) end end end diff --git a/app/domain/authentication/authn_oidc/v2/validations/authenticator_configuration.rb b/app/domain/authentication/authn_oidc/v2/validations/authenticator_configuration.rb new file mode 100644 index 0000000000..2065baa9c2 --- /dev/null +++ b/app/domain/authentication/authn_oidc/v2/validations/authenticator_configuration.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Authentication + module AuthnOidc + module V2 + module Validations + + # This class validates the configuration of the OIDC Authenticator as defined + # by the authenticator's variables. + # + # All required and optional variables should be defined here, as well as any + # the validation of those input's values. + # + # This validations are executed against the data loaded from Conjur variables when + # the authenicator is loaded via the AuthenticatorRepository. + class AuthenticatorConfiguration < Authentication::Base::Validations + schema do + required(:account).filled(:string) + required(:service_id).filled(:string) + required(:provider_uri).filled(:string) + required(:client_id).filled(:string) + required(:client_secret).filled(:string) + required(:claim_mapping).value(:string) + + optional(:redirect_uri).value(:string) + optional(:response_type).value(:string) + optional(:provider_scope).value(:string) + optional(:name).value(:string) + optional(:token_ttl).value(:string) + optional(:provider_scope).value(:string) + optional(:ca_cert).value(:string) + end + end + end + end + end +end diff --git a/app/domain/authentication/authn_oidc/v2/views/provider_context.rb b/app/domain/authentication/authn_oidc/v2/views/provider_context.rb index 1e54b06e2b..6c24a56449 100644 --- a/app/domain/authentication/authn_oidc/v2/views/provider_context.rb +++ b/app/domain/authentication/authn_oidc/v2/views/provider_context.rb @@ -7,7 +7,7 @@ module V2 module Views class ProviderContext def initialize( - client: Authentication::AuthnOidc::V2::Client, + client: Authentication::AuthnOidc::V2::OidcClient, digest: Digest::SHA256, random: SecureRandom, logger: Rails.logger @@ -16,48 +16,55 @@ def initialize( @logger = logger @digest = digest @random = random + + @success = SuccessResponse + @failure = FailureResponse end def call(authenticators:) - authenticators.map do |authenticator| - begin - nonce = @random.hex(25) - code_verifier = @random.hex(25) - code_challenge = @digest.base64digest(code_verifier).tr("+/", "-_").tr("=", "") - { + providers = [] + authenticators.each do |authenticator| + nonce = @random.hex(25) + code_verifier = @random.hex(25) + code_challenge = @digest.base64digest(code_verifier).tr("+/", "-_").tr("=", "") + + generate_redirect_url( + client: @client.new(authenticator: authenticator), + authenticator: authenticator, + nonce: nonce, + code_challenge: code_challenge + ).bind do |redirect_uri| + providers << { service_id: authenticator.service_id, type: 'authn-oidc', name: authenticator.name, nonce: nonce, code_verifier: code_verifier, - redirect_uri: generate_redirect_url( - client: @client.new(authenticator: authenticator), - authenticator: authenticator, - nonce: nonce, - code_challenge: code_challenge - ) + redirect_uri: redirect_uri } - rescue Errors::Authentication::OAuth::ProviderDiscoveryFailed, - Errors::Authentication::OAuth::ProviderDiscoveryTimeout - @logger.warn("Authn-OIDC '#{authenticator.service_id}' provider-uri: '#{authenticator.provider_uri}' is unreachable") - nil end - end.compact + end + providers end def generate_redirect_url(client:, authenticator:, nonce:, code_challenge:) - params = { - client_id: authenticator.client_id, - response_type: authenticator.response_type, - scope: ERB::Util.url_encode(authenticator.scope), - nonce: nonce, - code_challenge: code_challenge, - code_challenge_method: 'S256', - redirect_uri: ERB::Util.url_encode(authenticator.redirect_uri) - } - formatted_params = params.map { |key, value| "#{key}=#{value}" }.join("&") + client.oidc_configuration.bind do |config| + params = { + client_id: authenticator.client_id, + response_type: authenticator.response_type, + scope: ERB::Util.url_encode(authenticator.scope), + nonce: nonce, + code_challenge: code_challenge, + code_challenge_method: 'S256', + redirect_uri: ERB::Util.url_encode(authenticator.redirect_uri) + } + formatted_params = params.map { |key, value| "#{key}=#{value}" }.join("&") - "#{client.discovery_information.authorization_endpoint}?#{formatted_params}" + return @success.new("#{config['authorization_endpoint']}?#{formatted_params}") + end + message = "Authn-OIDC '#{authenticator.service_id}' provider-uri: '#{authenticator.provider_uri}' is unreachable" + @logger.warn(message) + @failure.new(message, exception: Errors::Authentication::OAuth::ProviderDiscoveryFailed) end end end diff --git a/app/domain/authentication/authn_oidc/validate_status.rb b/app/domain/authentication/authn_oidc/validate_status.rb index eb24824df1..3d005a4b15 100644 --- a/app/domain/authentication/authn_oidc/validate_status.rb +++ b/app/domain/authentication/authn_oidc/validate_status.rb @@ -3,9 +3,9 @@ module AuthnOidc ValidateStatus = CommandClass.new( dependencies: { - fetch_authenticator_secrets: Authentication::Util::FetchAuthenticatorSecrets.new, discover_identity_provider: Authentication::OAuth::DiscoverIdentityProvider.new, - required_variable_names: %w[provider-uri id-token-user-property] + required_variable_names: %w[provider-uri id-token-user-property], + optional_variable_names: %w[ca-cert] }, inputs: %i[account service_id] ) do @@ -26,7 +26,9 @@ def validate_secrets end def oidc_authenticator_secrets - @oidc_authenticator_secrets ||= @fetch_authenticator_secrets.( + @oidc_authenticator_secrets ||= Authentication::Util::FetchAuthenticatorSecrets.new( + optional_variable_names: @optional_variable_names + ).( service_id: @service_id, conjur_account: @account, authenticator_name: "authn-oidc", @@ -36,13 +38,18 @@ def oidc_authenticator_secrets def validate_provider_is_responsive @discover_identity_provider.( - provider_uri: provider_uri + provider_uri: provider_uri, + ca_cert: ca_cert ) end def provider_uri @oidc_authenticator_secrets["provider-uri"] end + + def ca_cert + @oidc_authenticator_secrets["ca-cert"] + end end end end diff --git a/app/domain/authentication/base/data_object.rb b/app/domain/authentication/base/data_object.rb new file mode 100644 index 0000000000..4f58958f0d --- /dev/null +++ b/app/domain/authentication/base/data_object.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Authentication + module Base + class DataObject + attr_reader(:account, :service_id) + + def initialize(account:, service_id: nil) + @account = account + @service_id = service_id + end + + def type + @type ||= self.class.to_s.split('::')[1].underscore.dasherize + end + + def identifier + [type, @service_id].compact.join('/') + end + + def resource_id + [ + @account, + 'webservice', + [ + 'conjur', + type, + @service_id + ].compact.join('/') + ].join(':') + end + + def variable_prefix + "#{@account}:variable:conjur/#{identifier}" + end + + def token_ttl + return ActiveSupport::Duration.parse(@token_ttl.to_s) if @token_ttl.to_s.present? + + # If token TTL has not been set on the authenticator, return nil so that it + # can be set using the configured user/host TTL values downstream. + nil + rescue ActiveSupport::Duration::ISO8601Parser::ParsingError + raise Errors::Authentication::DataObjects::InvalidTokenTTL.new(resource_id, @token_ttl) + end + + # TODO: required once role annotation check is implemented for authn-jwt. + # + # def annotations_required + # requires_role_annotations = self.class::REQUIRES_ROLE_ANNOTIONS + # return requires_role_annotations unless requires_role_annotations.nil? + + # raise "class constant 'REQUIRES_ROLED_ANNOTIONS' must be defined" + # end + end + end +end diff --git a/app/domain/authentication/base/validations.rb b/app/domain/authentication/base/validations.rb new file mode 100644 index 0000000000..c87c9a75cc --- /dev/null +++ b/app/domain/authentication/base/validations.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Authentication + module Base + class Validations < Dry::Validation::Contract + + # key is the context we're adding this custom error to + def failed_response(error:, key:) + key.failure(exception: error, text: error.message) + end + + end + end +end diff --git a/app/domain/authentication/command_handlers/authentication.rb b/app/domain/authentication/command_handlers/authentication.rb new file mode 100644 index 0000000000..42c99adc39 --- /dev/null +++ b/app/domain/authentication/command_handlers/authentication.rb @@ -0,0 +1,210 @@ +# frozen_string_literal: true + +module Authentication + module CommandHandlers + class Authentication + # rubocop:disable Metrics/ParameterLists + def initialize( + authenticator_type:, + authenticator_repository: ::DB::Repository::AuthenticatorRepository.new, + klass_loader_library: ::Authentication::Util::V2::KlassLoader, + logger: Rails.logger, + audit_logger: ::Audit.logger, + authentication_error: LogMessages::Authentication::AuthenticationError, + available_authenticators: ::Authentication::InstalledAuthenticators, + role_resource: ::Role, + authorization: ::RBAC::Permission.new, + token_factory: ::TokenFactory.new, + validator: ::DB::Validation + ) + @authenticator_type = authenticator_type + @logger = logger + @audit_logger = audit_logger + @authentication_error = authentication_error + @available_authenticators = available_authenticators + @role_resource = role_resource + @authorization = authorization + @token_factory = token_factory + @authenticator_repository = authenticator_repository + @validator = validator + + klass_loader = klass_loader_library.new(authenticator_type) + @strategy = klass_loader.strategy + @authenticator_klass = klass_loader.data_object + @authenticator_validation = klass_loader.authenticator_validation + + @success = ::SuccessResponse + @failure = ::FailureResponse + end + # rubocop:enable Metrics/ParameterLists + + def call(request_ip:, parameters:, request_body: nil) + service_id = parameters[:service_id] + account = parameters[:account] + role_for_audit = nil + identified_authenticator = nil + + response = retrieve_authenticator(service_id: service_id, account: account).bind do |authenticator| + identified_authenticator = authenticator + identify_role(authenticator: authenticator, parameters: parameters, request_body: request_body).bind do |role_identifier| + retrieve_role(role_identifier: role_identifier).bind do |role| + role_for_audit = role + check_usage_permitted(role: role, authenticator: authenticator).bind do |check_permitted_role| + check_origin_permitted(role: check_permitted_role, request_ip: request_ip).bind do |check_allowed_role| + issue_authentication_token(account: account, login: check_allowed_role.login, ttl: authenticator.token_ttl).bind do |token| + log_audit_success( + service: authenticator, + role_id: role.role_id, + request_ip: request_ip, + authenticator_type: authenticator.type + ) + return @success.new(token) + end + end + end + end + end + end + + role_identifier = if role_for_audit.is_a?(String) + role_for_audit + elsif role_for_audit.nil? && parameters[:id].present? + @role_resource.roleid_from_username(parameters[:account], parameters[:id]) + else + role_for_audit&.role_id + end + + log_audit_failure( + service: identified_authenticator, + role_id: role_identifier, + request_ip: request_ip, + authenticator_type: identified_authenticator&.type, + error_message: response.message + ) + + response + rescue => e + @failure.new(e.message, exception: e, backtrace: e.backtrace) + end + + def params_allowed + allowed = %i[authenticator service_id account id] + allowed += @strategy::ALLOWED_PARAMS if @strategy.const_defined?('ALLOWED_PARAMS') + allowed + end + + private + + def issue_authentication_token(account:, login:, ttl:) + @success.new( + @token_factory.signed_token( + account: account, + username: login, + host_ttl: ttl || Rails.application.config.conjur_config.host_authorization_token_ttl, + user_ttl: ttl || Rails.application.config.conjur_config.user_authorization_token_ttl + ) + ) + end + + def check_origin_permitted(role:, request_ip:) + if role.valid_origin?(request_ip) + @success.new(role) + else + @failure.new( + role, + status: :unauthorized, + exception: Errors::Authentication::InvalidOrigin.new + ) + end + end + + def check_usage_permitted(role:, authenticator:) + return @success.new(role) if @available_authenticators.native_authenticators.include?(authenticator.identifier) + + # Verify that the identified role is permitted to use this authenticator + @authorization.permitted?( + role: role, + resource_id: authenticator.resource_id, + privilege: :authenticate + ) + end + + def retrieve_role(role_identifier:) + role = @role_resource[role_identifier.identifier] + return @success.new(role) if role + + @failure.new( + "Failed to find role for: '#{role_identifier.identifier}'", + exception: Errors::Authentication::Security::RoleNotFound.new(role_identifier.role_for_error), + status: :bad_request + ) + end + + def identify_role(authenticator:, parameters:, request_body:) + @strategy.new( + authenticator: authenticator + ).callback(parameters: parameters, request_body: request_body) + end + + def retrieve_authenticator(service_id:, account:) + identifier = [@authenticator_type, service_id].compact.join('/') + # verify authenticator is whitelisted.... + unless @available_authenticators.enabled_authenticators.include?(identifier) || @available_authenticators.native_authenticators.include?(identifier) + return @failure.new( + "Authenticator: '#{identifier}' is not enabled.", + status: :bad_request, + exception: Errors::Authentication::Security::AuthenticatorNotWhitelisted.new(identifier) + ) + end + + # If this is a native authenticator (like API Key), it won't be stored + # as webservice, so just load the authenticator. + if @available_authenticators.native_authenticators.include?(identifier) + @success.new(@authenticator_klass.new(account: account)) + else + # Load Authenticator policy and variables + @authenticator_repository.find( + type: @authenticator_type, + account: account, + service_id: service_id + ).bind do |authenticator_data| + # validate data against authenticator specific validations + @validator.new(@authenticator_validation) + .validate(authenticator_data).bind do |validated_authenticator_data| + # Instantiate and return authenticator data object for future use + @success.new(@authenticator_klass.new(**validated_authenticator_data)) + end + end + end + rescue => e + @failure.new(e.message, exception: e) + end + + def log_audit_success(service:, role_id:, request_ip:, authenticator_type:) + @audit_logger.log( + ::Audit::Event::Authn::Authenticate.new( + authenticator_name: authenticator_type, + service: service, + role_id: role_id, + client_ip: request_ip, + success: true, + error_message: nil + ) + ) + end + + def log_audit_failure(service:, role_id:, request_ip:, authenticator_type:, error_message:) + @audit_logger.log( + ::Audit::Event::Authn::Authenticate.new( + authenticator_name: authenticator_type, + service: service, + role_id: role_id, + client_ip: request_ip, + success: false, + error_message: error_message + ) + ) + end + end + end +end diff --git a/app/domain/authentication/handler/authentication_handler.rb b/app/domain/authentication/handler/authentication_handler.rb deleted file mode 100644 index 8e2a36d22c..0000000000 --- a/app/domain/authentication/handler/authentication_handler.rb +++ /dev/null @@ -1,148 +0,0 @@ -# frozen_string_literal: true - -module Authentication - module Handler - class AuthenticationHandler - def initialize( - authenticator_type:, - role: ::Role, - resource: ::Resource, - authn_repo: DB::Repository::AuthenticatorRepository, - namespace_selector: Authentication::Util::NamespaceSelector, - logger: Rails.logger, - authentication_error: LogMessages::Authentication::AuthenticationError - ) - @role = role - @resource = resource - @authenticator_type = authenticator_type - @logger = logger - @authentication_error = authentication_error - - # Dynamically load authenticator specific classes - namespace = namespace_selector.select( - authenticator_type: authenticator_type - ) - - @identity_resolver = "#{namespace}::ResolveIdentity".constantize - @strategy = "#{namespace}::Strategy".constantize - @authn_repo = authn_repo.new( - data_object: "#{namespace}::DataObjects::Authenticator".constantize - ) - end - - def call(parameters:, request_ip:) - # Load Authenticator policy and values (validates data stored as variables) - authenticator = @authn_repo.find( - type: @authenticator_type, - account: parameters[:account], - service_id: parameters[:service_id] - ) - - if authenticator.nil? - raise( - Errors::Conjur::RequestedResourceNotFound, - "Unable to find authenticator with account: #{parameters[:account]} and service-id: #{parameters[:service_id]}" - ) - end - - role = @identity_resolver.new.call( - identity: @strategy.new( - authenticator: authenticator - ).callback(parameters), - account: parameters[:account], - allowed_roles: @role.that_can( - :authenticate, - @resource[authenticator.resource_id] - ).all - ) - - # TODO: Add an error message - raise 'failed to authenticate' unless role - - unless role.valid_origin?(request_ip) - raise Errors::Authentication::InvalidOrigin - end - - log_audit_success(authenticator, role, request_ip, @authenticator_type) - - TokenFactory.new.signed_token( - account: parameters[:account], - username: role.role_id.split(':').last, - user_ttl: authenticator.token_ttl - ) - rescue => e - log_audit_failure(parameters[:account], parameters[:service_id], request_ip, @authenticator_type, e) - handle_error(e) - end - - def handle_error(err) - @logger.info("#{err.class.name}: #{err.message}") - - case err - when Errors::Authentication::Security::RoleNotAuthorizedOnResource - raise ApplicationController::Forbidden - - when Errors::Authentication::RequestBody::MissingRequestParam, - Errors::Authentication::AuthnOidc::TokenVerificationFailed, - Errors::Authentication::AuthnOidc::TokenRetrievalFailed - raise ApplicationController::BadRequest - - when Errors::Conjur::RequestedResourceNotFound - raise ApplicationController::RecordNotFound.new(err.message) - - when Errors::Authentication::AuthnOidc::IdTokenClaimNotFoundOrEmpty - raise ApplicationController::Unauthorized - - when Errors::Authentication::Jwt::TokenExpired - raise ApplicationController::Unauthorized.new(err.message, true) - - when Errors::Authentication::Security::RoleNotFound - raise ApplicationController::BadRequest - - when Errors::Authentication::Security::MultipleRoleMatchesFound - raise ApplicationController::Forbidden - # Code value mismatch - when Rack::OAuth2::Client::Error - raise ApplicationController::BadRequest - - else - raise ApplicationController::Unauthorized - end - end - - def log_audit_success(authenticator, conjur_role, client_ip, type) - ::Authentication::LogAuditEvent.new.call( - authentication_params: - Authentication::AuthenticatorInput.new( - authenticator_name: "#{type}", - service_id: authenticator.service_id, - account: authenticator.account, - username: conjur_role.role_id, - client_ip: client_ip, - credentials: nil, - request: nil - ), - audit_event_class: Audit::Event::Authn::Authenticate, - error: nil - ) - end - - def log_audit_failure(account, service_id, client_ip, type, error) - ::Authentication::LogAuditEvent.new.call( - authentication_params: - Authentication::AuthenticatorInput.new( - authenticator_name: "#{type}", - service_id: service_id, - account: account, - username: nil, - client_ip: client_ip, - credentials: nil, - request: nil - ), - audit_event_class: Audit::Event::Authn::Authenticate, - error: error - ) - end - end - end -end diff --git a/app/domain/authentication/installed_authenticators.rb b/app/domain/authentication/installed_authenticators.rb index 295d5c19de..b93b066b61 100644 --- a/app/domain/authentication/installed_authenticators.rb +++ b/app/domain/authentication/installed_authenticators.rb @@ -7,10 +7,15 @@ class InstalledAuthenticators class << self def authenticators(env, authentication_module: ::Authentication) - loaded_authenticators(authentication_module) + v2_authenticators = Authentication::Util::V2::AuthenticatorLoader.all + + v1_authenticators = loaded_authenticators(authentication_module) .select { |cls| valid?(cls) } .map { |cls| [url_for(cls), authenticator_instance(cls, env)] } .to_h + + # Merge the V1 and V2 authenticators prioritizing V1 + v2_authenticators.merge(v1_authenticators) end def login_authenticators(env, authentication_module: ::Authentication) @@ -35,7 +40,7 @@ def configured_authenticators def enabled_authenticators # Enabling via environment overrides enabling via CLI - authenticators = + authenticators = Rails.application.config.conjur_config.authenticators authenticators.empty? ? db_enabled_authenticators : authenticators end @@ -44,8 +49,12 @@ def enabled_authenticators_str enabled_authenticators.join(',') end + def native_authenticators + %w[authn] + end + private - + def db_enabled_authenticators # Always include 'authn' when enabling authenticators via CLI so that it # doesn't get disabled when another authenticator is enabled diff --git a/app/domain/authentication/jwt/verify_and_decode_token.rb b/app/domain/authentication/jwt/verify_and_decode_token.rb index 026f30dddd..b68e939247 100644 --- a/app/domain/authentication/jwt/verify_and_decode_token.rb +++ b/app/domain/authentication/jwt/verify_and_decode_token.rb @@ -40,12 +40,12 @@ def verified_and_decoded_token @verification_options ).first - @logger.debug(LogMessages::Authentication::Jwt::TokenDecodeSuccess.new) + @logger.debug{LogMessages::Authentication::Jwt::TokenDecodeSuccess.new} @verified_and_decoded_token rescue JWT::ExpiredSignature raise Errors::Authentication::Jwt::TokenExpired rescue JWT::DecodeError => e - @logger.debug(LogMessages::Authentication::Jwt::TokenDecodeFailed.new(e.inspect)) + @logger.debug{LogMessages::Authentication::Jwt::TokenDecodeFailed.new(e.inspect)} raise Errors::Authentication::Jwt::TokenDecodeFailed, e.inspect rescue => e raise Errors::Authentication::Jwt::TokenVerificationFailed, e.inspect diff --git a/app/domain/authentication/o_auth/discover_identity_provider.rb b/app/domain/authentication/o_auth/discover_identity_provider.rb index aeb89794f9..02974d0d69 100644 --- a/app/domain/authentication/o_auth/discover_identity_provider.rb +++ b/app/domain/authentication/o_auth/discover_identity_provider.rb @@ -1,12 +1,16 @@ module Authentication module OAuth + # Object to match the previously used `OpenIDConnect::Discovery::Provider::Config::Resource` + # object used as part of the OpenIDConnect library. This is needed until we can fully + # port all existing JWT based authenticators to the new authenticator architecture. + DiscoveryProvider = Struct.new(:jwks, :supported_algorithms, keyword_init: true) DiscoverIdentityProvider = CommandClass.new( dependencies: { logger: Rails.logger, - open_id_discovery_service: OpenIDConnect::Discovery::Provider::Config + client: Authentication::Util::NetworkTransporter }, - inputs: %i[provider_uri] + inputs: %i[provider_uri ca_cert] ) do def call log_provider_uri @@ -16,31 +20,40 @@ def call private def log_provider_uri - @logger.debug( + @logger.debug{ LogMessages::Authentication::OAuth::IdentityProviderUri.new( @provider_uri ) - ) + } end - # returns an OpenIDConnect::Discovery::Provider::Config::Resource instance. - # While this leaks 3rd party code into ours, the only time this Resource - # is used is inside of FetchProviderKeys. This is unlikely change, and hence - # unlikely to be a problem + # Returns a mocked version of OpenIDConnect::Discovery::Provider::Config::Resource + # instance. While this leaks 3rd party code into ours, the only time this Resource + # is used is inside of FetchProviderKeys. This is unlikely to change, and hence + # unlikely to be a problem. def discover_provider - @discovered_provider = @open_id_discovery_service.discover!(@provider_uri) - @logger.debug( - LogMessages::Authentication::OAuth::IdentityProviderDiscoverySuccess.new - ) - @discovered_provider - rescue HTTPClient::ConnectTimeoutError, Errno::ETIMEDOUT => e - raise_error(Errors::Authentication::OAuth::ProviderDiscoveryTimeout, e) - rescue => e - raise_error(Errors::Authentication::OAuth::ProviderDiscoveryFailed, e) - end + well_known_stub = @provider_uri[-1] == "/" ? ".well-known/openid-configuration" : "/.well-known/openid-configuration" + response = @client.new(hostname: @provider_uri, ca_certificate: @ca_cert).get("#{@provider_uri}#{well_known_stub}").bind do |endpoint| + @logger.debug{LogMessages::Authentication::OAuth::IdentityProviderDiscoverySuccess.new} + @client.new(hostname: endpoint['jwks_uri'], ca_certificate: @ca_cert).get(endpoint['jwks_uri']).bind do |jwks| + return DiscoveryProvider.new( + jwks: jwks['keys'], + supported_algorithms: endpoint['id_token_signing_alg_values_supported'] + ) + end + end - def raise_error(error_class, original_error) - raise error_class.new(@provider_uri, original_error.inspect) + if response.exception.is_a?(Errno::ETIMEDOUT) + raise Errors::Authentication::OAuth::ProviderDiscoveryTimeout.new( + @provider_uri, + response.exception + ) + else + raise Errors::Authentication::OAuth::ProviderDiscoveryFailed.new( + @provider_uri, + response.exception + ) + end end end end diff --git a/app/domain/authentication/o_auth/fetch_provider_keys.rb b/app/domain/authentication/o_auth/fetch_provider_keys.rb index 3f021423c5..977eed309d 100644 --- a/app/domain/authentication/o_auth/fetch_provider_keys.rb +++ b/app/domain/authentication/o_auth/fetch_provider_keys.rb @@ -8,7 +8,7 @@ module OAuth logger: Rails.logger, discover_identity_provider: DiscoverIdentityProvider.new }, - inputs: %i[provider_uri] + inputs: %i[provider_uri ca_cert] ) do def call discover_provider @@ -23,17 +23,20 @@ def discover_provider def discovered_provider @discovered_provider ||= @discover_identity_provider.( - provider_uri: @provider_uri + provider_uri: @provider_uri, + ca_cert: @ca_cert ) end def fetch_provider_keys - jwks = { + # We have to wrap the call to the JWKS URI separately to accommodate + # custom CA certs in authn-oidc + jwks_keys = { keys: @discovered_provider.jwks } - algs = @discovered_provider.id_token_signing_alg_values_supported - @logger.debug(LogMessages::Authentication::OAuth::FetchProviderKeysSuccess.new) - ProviderKeys.new(jwks, algs) + algs = @discovered_provider.supported_algorithms + @logger.debug{LogMessages::Authentication::OAuth::FetchProviderKeysSuccess.new} + ProviderKeys.new(jwks_keys, algs) rescue => e raise Errors::Authentication::OAuth::FetchProviderKeysFailed.new( @provider_uri, diff --git a/app/domain/authentication/o_auth/verify_and_decode_token.rb b/app/domain/authentication/o_auth/verify_and_decode_token.rb index da87403def..836671894b 100644 --- a/app/domain/authentication/o_auth/verify_and_decode_token.rb +++ b/app/domain/authentication/o_auth/verify_and_decode_token.rb @@ -23,7 +23,7 @@ module OAuth verify_and_decode_token: ::Authentication::Jwt::VerifyAndDecodeToken.new, logger: Rails.logger }, - inputs: %i[provider_uri token_jwt claims_to_verify] + inputs: %i[provider_uri token_jwt claims_to_verify ca_cert] ) do def call fetch_provider_keys @@ -35,14 +35,15 @@ def call def fetch_provider_keys(force_read: false) provider_keys = @fetch_provider_keys.call( provider_uri: @provider_uri, - refresh: force_read + refresh: force_read, + ca_cert: @ca_cert ) @jwks = provider_keys.jwks @algs = provider_keys.algorithms - @logger.debug( + @logger.debug{ LogMessages::Authentication::OAuth::IdentityProviderKeysFetchedFromCache.new - ) + } end # ensure_keys_are_fresh will try to verify and decode the token and if it @@ -58,9 +59,9 @@ def verify_and_decode_token def ensure_keys_are_fresh verified_and_decoded_token rescue - @logger.debug( + @logger.debug{ LogMessages::Authentication::OAuth::ValidateProviderKeysAreUpdated.new - ) + } # maybe failed due to keys rotation. Force cache to read it again fetch_provider_keys(force_read: true) end diff --git a/app/domain/authentication/optional_api_key.rb b/app/domain/authentication/optional_api_key.rb new file mode 100644 index 0000000000..8fa7ef9b26 --- /dev/null +++ b/app/domain/authentication/optional_api_key.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Authentication + module OptionalApiKey + + AUTHN_ANNOTATION = 'authn/api-key' + + def api_key_annotation_relevant?(annotation) + annotation.name == AUTHN_ANNOTATION + end + + def api_key_annotation_true?(annotation) + api_key_annotation_relevant?(annotation) && annotation.value.downcase == 'true' + end + + end +end diff --git a/app/domain/authentication/readme_assets/authenticator-response-overview.puml b/app/domain/authentication/readme_assets/authenticator-response-overview.puml new file mode 100644 index 0000000000..6bd8fb340f --- /dev/null +++ b/app/domain/authentication/readme_assets/authenticator-response-overview.puml @@ -0,0 +1,66 @@ +@startuml +|Controller| +start +:Request Made; +if (Authenticator and Service ID whitelisted?) then (yes) + if (Authenticator Webservice exists?) then (yes) + if (Are required variables present in correct format?) then (yes) + if (Is Authentication payload authentic?) then (yes) + if (Is there a potential identifier?) then (yes) + if (Does matching Conjur role exist?) then (yes) + if (Is role allowed to authenticate?) then (yes) + if (From the request IP address?) then (yes) + :Generate Auth Token; + :Generate Audit success message; + #green:Return Auth Token; + end + else (no) + :Generate Audit failure message; + :Log exception: ; + #indianred:Request Status:\n[400] bad request; + end + endif + else (no) + :Generate Audit failure message; + :Log exception: ; + #indianred:Request Status:\n[400] bad request; + end + endif + else (no) + :Generate Audit failure message; + :Log exception: ; + #indianred:Request Status:\n[400] bad request; + end + endif + else (no) + :Generate Audit failure message; + :Log exception: ; + #indianred:Request Status:\n[400] bad request; + end + endif + else (no) + :Generate Audit failure message; + :Log exception: ; + #indianred:Request Status:\n[400] bad request; + end + endif + else (no) + :Generate Audit failure message; + :Log exception: ; + #indianred:Request Status:\n[401] unauthorized; + end + endif + else (no) + :Generate Audit failure message; + :Log exception: \nErrors::Authentication::Security::WebserviceNotFound; + #indianred:Request Status:\n[401] unauthorized; + end + endif +else(no) + :Generate Audit failure message; + :Log exception: \nErrors::Authentication::Security::AuthenticatorNotWhitelisted; + #indianred:Request Status:\n[400] bad request; + end +endif +end +@enduml diff --git a/app/domain/authentication/readme_assets/authenticator-workflow-components.png b/app/domain/authentication/readme_assets/authenticator-workflow-components.png new file mode 100644 index 0000000000..b11cebda11 Binary files /dev/null and b/app/domain/authentication/readme_assets/authenticator-workflow-components.png differ diff --git a/app/domain/authentication/readme_assets/authenticator-workflow-components.puml b/app/domain/authentication/readme_assets/authenticator-workflow-components.puml new file mode 100644 index 0000000000..3970fcaa8d --- /dev/null +++ b/app/domain/authentication/readme_assets/authenticator-workflow-components.puml @@ -0,0 +1,43 @@ +@startuml +:Authentication request from client; +package Authentication::CommandHandlers::Authentication { + if (Authenticator enabled?) then (no) + #pink:error; + detach + endif + package DB::Repository::AuthenticatorRepository { + if (Webservice exists?) then (no) + #pink:error; + detach + endif + :Retrieve relevant variables; + } + package DB::Validation { + if (Variable values are valid?) then (no) + #pink:error; + detach + endif + } + :Populate Data Object:\nAuthentication::Authn::V2::DataObjects::Authenticator; + package Authentication::Authn::V2::Strategy { + if (Is identity token valid?) then (no) + #pink:error; + detach + endif + :Extract relevant identifier information into:\nAuthentication::RoleIdentifier; + } + if (Does Identy map to a Conjur Role?) then (no) + #pink:error; + detach + endif + if (Is Role allowed to use this authenticator?) then (no) + #pink:error; + detach + endif + if (Is Role allowed to authenticate\nfrom its origin?) then (no) + #pink:error; + detach + endif + #palegreen:Generate Conjur auth token; +} +@enduml diff --git a/app/domain/authentication/readme_assets/authenticator-workflow-paths.png b/app/domain/authentication/readme_assets/authenticator-workflow-paths.png new file mode 100644 index 0000000000..0bdeb099a6 Binary files /dev/null and b/app/domain/authentication/readme_assets/authenticator-workflow-paths.png differ diff --git a/app/domain/authentication/readme_assets/authenticator-workflow-paths.puml b/app/domain/authentication/readme_assets/authenticator-workflow-paths.puml new file mode 100644 index 0000000000..a8631dd4a0 --- /dev/null +++ b/app/domain/authentication/readme_assets/authenticator-workflow-paths.puml @@ -0,0 +1,65 @@ +@startuml +start +if (Authenticator and Service ID in Allowlist?) then (#green:yes) + if (Authenticator Webservice exists?) then (#green:yes) + if (Are required variables present in correct format?) then (#green:yes) + if (Is Authentication payload authentic?) then (#green:yes) + if (Is there a potential identifier?) then (#green:yes) + if (Does matching Conjur role exist?) then (#green:yes) + if (Is role allowed to authenticate?) then (#green:yes) + if (From the request IP address?) then (#green:yes) + :Generate Auth Token; + :Generate Audit success message; + #green:Return Auth Token; + end + else (no) + :Generate Audit failure message; + :Log exception: ; + #indianred:Status: bad request\nCode: 400; + end + endif + else (no) + :Generate Audit failure message; + :Log exception: ; + #indianred:Status: bad request\nCode: 400; + end + endif + else (no) + :Generate Audit failure message; + :Log exception: ; + #indianred:Status: bad request\nCode: 400; + end + endif + else (no) + :Generate Audit failure message; + :Log exception: ; + #indianred:Status: bad request\nCode: 400; + end + endif + else (no) + :Generate Audit failure message; + :Log exception: ; + #indianred:Status: bad request\nCode: 400; + end + endif + else (no) + :Generate Audit failure message; + #indianred:Exception: + Status: unauthorized + Code: 401; + end + endif + else (no) + :Generate Audit failure message; + :Log exception: Errors::Authentication::Security::WebserviceNotFound; + #indianred:Status: unauthorized\nCode: 401; + end + endif +else(no) + :Generate Audit failure message; + :Log exception: Errors::Authentication::Security::AuthenticatorNotWhitelisted; + #indianred:Status: bad request\nCode: 400; + end +endif +end +@enduml diff --git a/app/domain/authentication/resource_restrictions/extract_resource_restrictions.rb b/app/domain/authentication/resource_restrictions/extract_resource_restrictions.rb index fe3094ac86..2a83dbe8a2 100644 --- a/app/domain/authentication/resource_restrictions/extract_resource_restrictions.rb +++ b/app/domain/authentication/resource_restrictions/extract_resource_restrictions.rb @@ -17,19 +17,19 @@ module ResourceRestrictions inputs: %i[authenticator_name service_id role_name account] ) do def call - @logger.info( + @logger.debug{ LogMessages::Authentication::ResourceRestrictions::ExtractingRestrictionsFromResource.new( @authenticator_name, @role_name ) - ) + } fetch_resource_annotations extract_resource_restrictions_from_annotations create_resource_restrictions_object validate_restriction_configuration - @logger.debug(LogMessages::Authentication::ResourceRestrictions::ExtractedResourceRestrictions.new(resource_restrictions.names)) + @logger.debug{LogMessages::Authentication::ResourceRestrictions::ExtractedResourceRestrictions.new(resource_restrictions.names)} resource_restrictions end @@ -71,7 +71,7 @@ def add_restriction_to_hash(annotation_name, annotation_value, resource_restrict # General restriction should not override existing restriction return if is_general_restriction && resource_restrictions_hash.include?(restriction_name) - @logger.info(LogMessages::Authentication::ResourceRestrictions::RetrievedAnnotationValue.new(annotation_name)) + @logger.debug{LogMessages::Authentication::ResourceRestrictions::RetrievedAnnotationValue.new(annotation_name)} resource_restrictions_hash[restriction_name] = annotation_value end @@ -89,7 +89,7 @@ def resource_restrictions def validate_restriction_configuration return unless @restriction_configuration_validator - @logger.debug(LogMessages::Authentication::ResourceRestrictions::ValidatingRestrictionConfiguration.new) + @logger.debug{LogMessages::Authentication::ResourceRestrictions::ValidatingRestrictionConfiguration.new} resource_restrictions.each do |restriction| @restriction_configuration_validator.call( @@ -97,7 +97,7 @@ def validate_restriction_configuration ) end - @logger.debug(LogMessages::Authentication::ResourceRestrictions::ValidatedRestrictionConfigurationSuccessfully.new) + @logger.debug{LogMessages::Authentication::ResourceRestrictions::ValidatedRestrictionConfigurationSuccessfully.new} end end end diff --git a/app/domain/authentication/resource_restrictions/validate_resource_restrictions.rb b/app/domain/authentication/resource_restrictions/validate_resource_restrictions.rb index 1487e98db9..d26b63b6bc 100644 --- a/app/domain/authentication/resource_restrictions/validate_resource_restrictions.rb +++ b/app/domain/authentication/resource_restrictions/validate_resource_restrictions.rb @@ -28,13 +28,13 @@ module ResourceRestrictions inputs: %i[authenticator_name service_id role_name account constraints authentication_request] ) do def call - @logger.debug(LogMessages::Authentication::ResourceRestrictions::ValidatingResourceRestrictions.new(@role_name)) + @logger.debug{LogMessages::Authentication::ResourceRestrictions::ValidatingResourceRestrictions.new(@role_name)} extract_resource_restrictions validate_resource_restrictions_configuration validate_request_matches_resource_restrictions - @logger.debug(LogMessages::Authentication::ResourceRestrictions::ValidatedResourceRestrictions.new) + @logger.debug{LogMessages::Authentication::ResourceRestrictions::ValidatedResourceRestrictions.new} end private @@ -53,27 +53,27 @@ def resource_restrictions end def validate_resource_restrictions_configuration - @logger.debug(LogMessages::Authentication::ResourceRestrictions::ValidatingResourceRestrictionsConfiguration.new) + @logger.debug{LogMessages::Authentication::ResourceRestrictions::ValidatingResourceRestrictionsConfiguration.new} @constraints.validate( resource_restrictions: resource_restrictions.names ) - @logger.debug(LogMessages::Authentication::ResourceRestrictions::ValidatedResourceRestrictionsConfiguration.new) + @logger.debug{LogMessages::Authentication::ResourceRestrictions::ValidatedResourceRestrictionsConfiguration.new} end def validate_request_matches_resource_restrictions - @logger.debug(LogMessages::Authentication::ResourceRestrictions::ValidatingResourceRestrictionsValues.new) + @logger.debug{LogMessages::Authentication::ResourceRestrictions::ValidatingResourceRestrictionsValues.new} resource_restrictions.each do |restriction| - @logger.debug(LogMessages::Authentication::ResourceRestrictions::ValidatingResourceRestrictionOnRequest.new(restriction.name)) + @logger.debug{LogMessages::Authentication::ResourceRestrictions::ValidatingResourceRestrictionOnRequest.new(restriction.name)} next if @authentication_request.valid_restriction?(restriction) raise Errors::Authentication::ResourceRestrictions::InvalidResourceRestrictions, restriction.name end - @logger.debug(LogMessages::Authentication::ResourceRestrictions::ValidatedResourceRestrictionsValues.new) + @logger.debug{LogMessages::Authentication::ResourceRestrictions::ValidatedResourceRestrictionsValues.new} end end end diff --git a/app/domain/authentication/role_identifier.rb b/app/domain/authentication/role_identifier.rb new file mode 100644 index 0000000000..a9f71becdb --- /dev/null +++ b/app/domain/authentication/role_identifier.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Authentication + class RoleIdentifier + attr_reader :identifier, :annotations + + def initialize(identifier:, annotations: {}) + @identifier = identifier + @annotations = annotations + end + + def type + @identifier.split(':')[1] + end + + def account + @identifier.split(':')[0] + end + + # Role identifier within the account and type context: + # :: + def id + @identifier.split(':')[2] + end + + def role_for_error + type == 'host' ? "host/#{id}" : id + end + end +end diff --git a/app/domain/authentication/util/check_authenticator_secret_exists.rb b/app/domain/authentication/util/check_authenticator_secret_exists.rb index 503eccc2b8..b0cf1f0a2e 100644 --- a/app/domain/authentication/util/check_authenticator_secret_exists.rb +++ b/app/domain/authentication/util/check_authenticator_secret_exists.rb @@ -19,7 +19,7 @@ def call private def check_authenticator_secret_exists - @logger.debug(LogMessages::Util::CheckingResourceExists.new(resource_id)) + @logger.debug{LogMessages::Util::CheckingResourceExists.new(resource_id)} resource ? true : false end diff --git a/app/domain/authentication/util/fetch_authenticator_secrets.rb b/app/domain/authentication/util/fetch_authenticator_secrets.rb index 32f97028cd..f62df47778 100644 --- a/app/domain/authentication/util/fetch_authenticator_secrets.rb +++ b/app/domain/authentication/util/fetch_authenticator_secrets.rb @@ -6,25 +6,37 @@ module Util FetchAuthenticatorSecrets = CommandClass.new( dependencies: { - fetch_secrets: ::Conjur::FetchRequiredSecrets.new + fetch_required_secrets: ::Conjur::FetchRequiredSecrets.new, + fetch_optional_secrets: ::Conjur::FetchOptionalSecrets.new, + optional_variable_names: [] }, inputs: %i[conjur_account authenticator_name service_id required_variable_names] ) do def call - @required_variable_names.each_with_object({}) do |variable_name, secrets| - full_variable_name = full_variable_name(variable_name) - secrets[variable_name] = required_secrets[full_variable_name] - end + secret_map_for(required_secrets).merge(secret_map_for(optional_secrets)) end private def required_secrets - @required_secrets ||= @fetch_secrets.(resource_ids: required_resource_ids) + @required_secrets ||= @fetch_required_secrets.(resource_ids: resource_ids_for(@required_variable_names)) + end + + def optional_secrets + @optional_secrets ||= @fetch_optional_secrets.(resource_ids: resource_ids_for(@optional_variable_names)) + end + + def secret_map_for(secret_values) + secret_values.each_with_object({}) do |(full_name, value), secrets| + # Strip the authenticator id prefix from the secret name to get the + # short name. + short_name = full_name.to_s.split('/')[3..].join('/') + secrets[short_name] = value + end end - def required_resource_ids - @required_variable_names.map { |var_name| full_variable_name(var_name) } + def resource_ids_for(variable_names) + variable_names.map { |var_name| full_variable_name(var_name) } end def full_variable_name(var_name) diff --git a/app/domain/authentication/util/host_authentication.rb b/app/domain/authentication/util/host_authentication.rb new file mode 100644 index 0000000000..0402427021 --- /dev/null +++ b/app/domain/authentication/util/host_authentication.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module HostAuthentication + def get_access_token(account, host_full_id, request) + host_name = Role.username_from_roleid(host_full_id) + host_role = Role[host_full_id] + # Authenticate + auth_input = Authentication::AuthenticatorInput.new( + authenticator_name: Authentication::Common.default_authenticator_name, + service_id: nil, + account: account, + username: host_name, + credentials: host_role.api_key, + client_ip: request.ip, + request: request + ) + installer_token = new_authenticate.call( + authenticator_input: auth_input, + authenticators: Authentication::InstalledAuthenticators.authenticators(ENV), + enabled_authenticators: Authentication::InstalledAuthenticators.enabled_authenticators_str + ) + Base64.strict_encode64(installer_token.to_json) + end + + private + def new_authenticate + Authentication::Authenticate.new + end + +end diff --git a/app/domain/authentication/util/klass_loader.rb b/app/domain/authentication/util/klass_loader.rb new file mode 100644 index 0000000000..42dca1ff62 --- /dev/null +++ b/app/domain/authentication/util/klass_loader.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +module Authentication + module Util + module V2 + class AuthenticatorLoader + class << self + def all + {}.tap do |rtn| + group_authenticators(authenticator_klasses).each do |authn_type, authn_klasses| + next if authn_klasses[:strategy].nil? || authn_klasses[:authenticator].nil? + + rtn[authn_type] = authn_klasses + end + end + end + + def authenticator_klasses + results = [] + load_klasses(mod: Authentication, klasses: results) + results.flatten.compact.uniq + end + + private + + def group_authenticators(klasses) + {}.tap do |grouped_files| + klasses.each do |klass| + parts = klass.to_s.split('::') + next unless parts[1].match(/^Authn/) + next unless parts[2] == 'V2' + + type = Authentication::Util::NamespaceSelector.module_to_type(parts[1]) + + grouped_files[type] ||= {} + case parts.last + when 'Authenticator' + grouped_files[type][:authenticator] = klass + when 'Strategy' + grouped_files[type][:strategy] = klass + end + end + end + end + + # Recursively loads all classes in the provided Module. This is + # used to "auto-magically" find and use relevant authenticators. + def load_klasses(mod:, klasses:) + mod.constants.each do |constant| + constant = mod.const_get(constant) + case constant + when Class + klasses << constant + when Module + load_klasses(mod: constant, klasses: klasses) + end + end + end + end + end + + # This is a utility to handle detection of Authenticator and Annotation + # validations. This enables us to optionally add validations for a particular + # authenticator. + # + class KlassLoader + def initialize(type, namespace_selector: Authentication::Util::NamespaceSelector) + @classified_type = namespace_selector.type_to_module(type) + end + + def strategy + find('Strategy') + end + + def data_object + find('DataObjects::Authenticator') + end + + def authenticator_validation + find('Validations::AuthenticatorConfiguration') + end + + private + + def find(name) + AuthenticatorLoader.authenticator_klasses.find { |klass| klass.name.match?("Authentication::#{@classified_type}::V2::#{name}") } + end + end + end + end +end diff --git a/app/domain/authentication/util/namespace_selector.rb b/app/domain/authentication/util/namespace_selector.rb index d168505d43..ab35c47314 100644 --- a/app/domain/authentication/util/namespace_selector.rb +++ b/app/domain/authentication/util/namespace_selector.rb @@ -3,16 +3,24 @@ module Authentication module Util class NamespaceSelector - def self.select(authenticator_type:) - case authenticator_type - when 'authn-oidc' - # 'V2' is a bit of a hack to handle the fact that - # the original OIDC authenticator is really a - # glorified JWT authenticator. - 'Authentication::AuthnOidc::V2' - else - raise "'#{authenticator_type}' is not a supported authenticator type" - # TODO: make this dynamic based on authenticator type. + class << self + def type_to_module(authenticator_type) + raise("Authenticator type is missing or nil") unless authenticator_type.present? + + mapping[authenticator_type] || authenticator_type.underscore.camelize + end + + def module_to_type(mod) + inverted_mapping = mapping.invert + inverted_mapping[mod] || mod.underscore.dasherize + end + + private + + def mapping + { + 'authn' => 'AuthnApiKey' + } end end end diff --git a/app/domain/authentication/util/network_transporter.rb b/app/domain/authentication/util/network_transporter.rb new file mode 100644 index 0000000000..da6b31da5f --- /dev/null +++ b/app/domain/authentication/util/network_transporter.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +module Authentication + module Util + class NetworkTransporter + def initialize( + hostname:, + ca_certificate: nil, + proxy: nil, + http: Net::HTTP, + certificate_utilities: Conjur::CertUtils, + http_post: Net::HTTP::Post + ) + # Set the default hostname + @uri = URI(hostname) + # Stripping path if present + @uri.path = '' + + @ca_certificate = ca_certificate + + @http = http + @http_post = http_post # to facilitate dependency injection for testing + @certificate_utilities = certificate_utilities + + # Find and set the proxy + @proxy = identify_proxy(proxy) + + @success = ::SuccessResponse + @failure = ::FailureResponse + end + + def get(path) + as_response do + get_request(request_path: path) + end + end + + def post(path:, body: '', basic_auth: [], headers: {}) + as_response do + post_request(path: path, body: body, basic_auth: basic_auth, headers: headers) + end + end + + private + + def identify_proxy(proxy) + proxy_url = if proxy.present? + proxy + elsif @uri.scheme == 'https' + ENV['HTTPS_PROXY'] || ENV['https_proxy'] || ENV['ALL_PROXY'] + else + ENV['http_proxy'] || ENV['ALL_PROXY'] + end + + proxy_uri = URI.parse(proxy_url.to_s) + proxy_uri.is_a?(URI::HTTP) ? proxy_uri : nil + end + + def get_request(request_path:) + http_client.start do |http| + http.get(URI(request_path).path) + end + end + + def post_request(path:, body: '', basic_auth: [], headers: {}) + http_client.start do |http| + request = @http_post.new(URI(path).path) + request.body = body + headers.each do |key, value| + request[key] = value + end + request.basic_auth(*basic_auth) unless basic_auth.empty? + http.request(request) + end + end + + def as_response(&block) + response = block.call + if response.code.match(/^2\d{2}/) + @success.new(JSON.parse(response.body.to_s)) + else + @failure.new("Error Response Code: '#{response.code}' from '#{response.uri}'") + end + rescue JSON::ParserError => e + @failure.new("Invalid JSON: #{e.message}", exception: e, status: :bad_request) + rescue => e + @failure.new("Invalid Request: #{e.message}", exception: e, status: :bad_request) + end + + # If proxy settings are set via environment variables, grab the relevant settingsq + def proxy_settings + return [] unless @proxy.present? + + # if proxy is present, set with the appropriate host and port. Set username and password if present. + [@proxy.host, @proxy.port, @proxy.user, @proxy.password].compact + end + + def http_client + @http_client ||= begin + http = @http.new(@uri.host, @uri.port, *proxy_settings) + return http unless @uri.instance_of?(URI::HTTPS) + + # Enable SSL support + http.use_ssl = true + http.verify_mode = OpenSSL::SSL::VERIFY_PEER + + store = OpenSSL::X509::Store.new + # If CA Certificate is available, add it to the certificate store + if @ca_certificate.present? + @certificate_utilities.add_chained_cert(store, @ca_certificate) + else + # Auto-include system CAs unless a CA has been defined + store.set_default_paths + end + http.cert_store = store + + # return the http object + http + end + end + end + end +end diff --git a/app/domain/authentication/validate_origin.rb b/app/domain/authentication/validate_origin.rb index 0209e2153d..4e9b38db5b 100644 --- a/app/domain/authentication/validate_origin.rb +++ b/app/domain/authentication/validate_origin.rb @@ -12,7 +12,7 @@ module Authentication def call raise Errors::Authentication::InvalidOrigin unless role.valid_origin?(@client_ip) - @logger.debug(LogMessages::Authentication::OriginValidated.new.to_s) + @logger.debug{LogMessages::Authentication::OriginValidated.new.to_s} end private diff --git a/app/domain/aws/app_config_data_client.rb b/app/domain/aws/app_config_data_client.rb new file mode 100644 index 0000000000..32f93b6a76 --- /dev/null +++ b/app/domain/aws/app_config_data_client.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true +require 'aws-sdk-core' + +module Aws + class AppConfigDataClient + + def get_latest_configuration + app_config = pull_from_app_config + tenant_name = Rails.application.config.conjur_config.tenant_name + extract_active_features(app_config, tenant_name) + end + + private + def pull_from_app_config + response = nil + tenant_env = Rails.application.config.conjur_config.tenant_env + tenant_region = Rails.application.config.conjur_config.tenant_region + + Aws.config.update(region: tenant_region) + + appconfig_client = Aws::AppConfigData::Client.new + # It's ok to start a new session every time because at most this call will be made once per minute + response = appconfig_client.start_configuration_session({ + application_identifier: "#{tenant_env}-feature-flags-conjur", + environment_identifier: "#{tenant_env}-feature-flags-stable", + configuration_profile_identifier: "#{tenant_env}-feature-flags-conjur" + }) + + response = appconfig_client.get_latest_configuration({ + configuration_token: response.initial_configuration_token + }) + + response.configuration.read + rescue => e + Rails.logger.error("Error pulling app config data: #{e}") + raise ApplicationController::InternalServerError, "Error pulling app config data" + e.message + ensure + response.configuration.close if response&.configuration + end + + def extract_active_features(json, tenant_name) + json_data = JSON.parse(json) + return json_data.select do |key, value| + if value == 'ALWAYS_ON' + true + elsif value == 'ALWAYS_OFF' + false + elsif value['ON_ONLY_FOR_SPECIFIC_TENANTS'] + value['ON_ONLY_FOR_SPECIFIC_TENANTS'].include?(tenant_name) + elsif value['OFF_ONLY_FOR_SPECIFIC_TENANTS'] + value['OFF_ONLY_FOR_SPECIFIC_TENANTS'].exclude?(tenant_name) + else + Rails.logger.error("Invalid value for feature flag #{key}: #{value}") + false # invalid value + end + end.keys + rescue => e + Rails.logger.error("Error parsing app config data: #{e.message}") + raise ApplicationController::UnprocessableEntity, "Invalid app config data: #{json} - #{e.message}" + end + + end +end diff --git a/app/domain/aws/sns_client.rb b/app/domain/aws/sns_client.rb new file mode 100644 index 0000000000..c56b570333 --- /dev/null +++ b/app/domain/aws/sns_client.rb @@ -0,0 +1,115 @@ +# app/domain/aws/sns_client.rb +require 'singleton' +require 'aws-sdk-sns' +require 'aws-sdk-sts' +require 'thread' # For Mutex + +class SnsClient + include Singleton + + def self.reset_instance + Singleton.__init__(self) + end + + def initialize( duration_seconds: 900) + @mutex = Mutex.new + @duration_seconds = duration_seconds # 15 minutes by default + @credentials = assume_role + @sns_client = create_sns_client(@credentials) + end + + def publish(message, message_attributes) + publish_message_with_error_handling(message, message_attributes) + end + + def sns_client + @sns_client + end + + private + + def publish_message_with_error_handling(message, message_attributes, second_try_to_publish: false) + unless credentials_valid? + handle_unauthorized_operation(message, message_attributes, second_try_to_publish) + end + Rails.logger.debug{"Publishing message to SNS topic."} + @sns_client.publish( + topic_arn: fetch_topic_arn, + message: message, + message_attributes: message_attributes, + message_group_id: fetch_tenant_id + ) + rescue => e + if credentials_valid? + Rails.logger.error("Failed to publish message: #{e.message}") + raise e + else + handle_unauthorized_operation(message, message_attributes, second_try_to_publish) + end + end + + def handle_unauthorized_operation( message, message_attributes, second_try_to_publish) + if second_try_to_publish + handle_failed_retry + else + # first try to publish - so we will try one more time to assume and publish msg + Rails.logger.debug{"Credentials expired, attempting to assume role and retry."} + assume_role + @sns_client = create_sns_client(@credentials) + publish_message_with_error_handling(message, message_attributes, second_try_to_publish: true) + end + end + + def handle_failed_retry + Rails.logger.error("Failed to publish message after retry attempt.") + raise Errors::Authentication::Security::UnauthorizedSnsRoleCreds + end + + def assume_role + @mutex.synchronize do + return @credentials if credentials_valid? + Rails.logger.debug{"Assuming role to publish message to SNS topic."} + tenant_id_tag = fetch_tenant_id.gsub('-', '') + tags = [{ key: 'tenant_id', value: "#{tenant_id_tag}" }] + sts_client = Aws::STS::Client.new + resp = sts_client.assume_role( + role_arn: fetch_role_arn, + role_session_name: "PublishSNSMessageSession", + tags: tags, + duration_seconds: @duration_seconds + ) + @credentials = resp.credentials + end + end + + def credentials_valid? + @credentials && @credentials.expiration > Time.now + end + + def create_sns_client(credentials) + Aws::SNS::Client.new( + region: fetch_tenant_region, + access_key_id: credentials.access_key_id, + secret_access_key: credentials.secret_access_key, + session_token: credentials.session_token + ) + end + + + + def fetch_tenant_id + Rails.application.config.conjur_config.tenant_id { raise "TENANT_ID not set in environment variables" } + end + + def fetch_topic_arn + Rails.application.config.conjur_config.try(:conjur_pubsub_sns_topic) { raise "conjur_pubsub_sns_topic not set in config" } + end + + def fetch_tenant_region + Rails.application.config.conjur_config.tenant_region { raise "TENANT_REGION not set in environment variables" } + end + + def fetch_role_arn + Rails.application.config.conjur_config.try(:conjur_pubsub_iam_role) { raise "conjur_pubsub_iam_role not set in config" } + end +end \ No newline at end of file diff --git a/app/domain/conjur/cert_utils.rb b/app/domain/conjur/cert_utils.rb index e2f4ed4e67..b7c11983f6 100644 --- a/app/domain/conjur/cert_utils.rb +++ b/app/domain/conjur/cert_utils.rb @@ -15,13 +15,19 @@ def parse_certs certs certs.gsub!("-----END\nCERTIFICATE-----", '-----END CERTIFICATE-----') certs += "\n" unless certs[-1] == "\n" - certs.scan(CERT_RE).map do |cert| - + parsed_certs = certs.scan(CERT_RE).map do |cert| OpenSSL::X509::Certificate.new(cert) rescue OpenSSL::X509::CertificateError => e raise e, "Invalid certificate:\n#{cert} (#{e.message})" - end + + # If no certificates were parsed, attempt to parse the original string + # and raise the underlying error + if parsed_certs.empty? + parsed_certs = Array(OpenSSL::X509::Certificate.new(certs)) + end + + parsed_certs end # Add a certificate to a given store. If the certificate has more than @@ -30,11 +36,22 @@ def parse_certs certs # adds only the intermediate certificate to the store. def add_chained_cert store, chained_cert parse_certs(chained_cert).each do |cert| - store.add_cert(cert) rescue OpenSSL::X509::StoreError => e raise unless e.message == 'cert already in hash table' - + end + end + + # Attempts to load all of the certificate files from a given directory + # into a certificate store. + def load_certificates(cert_store, ssl_cert_directory) + return unless Dir.exist?(ssl_cert_directory) + + Dir["#{ssl_cert_directory}/*"].each do |file_name| + # skip this iteration if the file doesn't exist + next unless File.exist?(file_name) + + add_chained_cert(cert_store, File.read(file_name)) end end end diff --git a/app/domain/conjur/fetch_optional_secrets.rb b/app/domain/conjur/fetch_optional_secrets.rb new file mode 100644 index 0000000000..0c3ab588e2 --- /dev/null +++ b/app/domain/conjur/fetch_optional_secrets.rb @@ -0,0 +1,31 @@ +require 'command_class' + +module Conjur + + FetchOptionalSecrets ||= CommandClass.new( + dependencies: { resource_class: ::Resource }, + inputs: [:resource_ids] + ) do + def call + secret_values + end + + private + + def secret_values + secrets.transform_values do |secret| + secret ? secret.value : nil + end + end + + def resources + @resources ||= @resource_ids.map { |id| [id, @resource_class[id]] }.to_h + end + + def secrets + @secrets ||= resources.transform_values do |resource| + resource ? resource.secret : nil + end + end + end +end diff --git a/app/domain/edge_logic/authenticators/authenticators_manager.rb b/app/domain/edge_logic/authenticators/authenticators_manager.rb new file mode 100644 index 0000000000..02a2c4b33d --- /dev/null +++ b/app/domain/edge_logic/authenticators/authenticators_manager.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true +module AuthenticatorsManager + + def get_authenticators_data(kinds) + return_json = {} + kinds.each do |kind| + if kind == "authn-jwt" + return_json[kind] = Authenticator.jwt.all + end + end + return_json + end + + def get_authenticators_parsed_data(kinds, offset, limit) + return_json = {} + kinds.each do |kind| + if kind == "authn-jwt" + return_json[kind] = authn_jwt_handler(offset, limit) + end + end + return_json + end + + def authn_jwt_handler(offset,limit) + results = [] + unique_properties = %w[jwksUri publicKeys caCert tokenAppProperty identityPath issuer enforcedClaims claimAliases audience] + unique_properties_name_in_policy = %w[jwks-uri public-keys ca-cert token-app-property identity-path issuer enforced-claims claim-aliases audience] + property_map = unique_properties_name_in_policy.zip(unique_properties).to_h + begin + authenticators = Authenticator.jwt.limit((limit || 1000).to_i, (offset || 0).to_i) + authenticators.each do |authenticator| + authenticatorToReturn = {} + authenticatorToReturn[:id] = authenticator[:resource_id] + # if the the enable is not configured the default value will be false + authenticatorToReturn[:enabled] = authenticator[:enabled] + # if there is no permissions for authenticator the default value will be nil + authenticatorToReturn[:permissions] = nil + if JSON.parse(authenticator[:permissions]).first["role_id"]!=nil + authenticatorToReturn[:permissions] = [] + JSON.parse(authenticator[:permissions]).each do |row| + permissionToReturn = {} + permissionToReturn[:role] = row["role_id"] + permissionToReturn[:privilege] = row["privilege"] + authenticatorToReturn[:permissions] << permissionToReturn + end + authenticatorToReturn[:permissions] = authenticatorToReturn[:permissions].sort_by { |item| item[:privilege] } + end + # set all the 8 unique properties to be nil by default + nil_properties_hash = unique_properties.map { |property| [property, nil] }.to_h + authenticatorToReturn.merge!(nil_properties_hash) + # if the property_id is in the claims list is means the claims configured + # if it configured in the policy the property value should be empty string + # if the property set to a new value, it should be the actual value + JSON.parse(authenticator[:claims]).each do |claim| + full_id = claim["property_id"] + key = full_id.split("/").last + key_mapping = property_map[key] + property_value = Authenticator.get_property(full_id) + if key_mapping == "enforcedClaims" + authenticatorToReturn[key_mapping] = build_enforced_claims(property_value) + elsif key_mapping == "claimAliases" + authenticatorToReturn[key_mapping] = build_claim_aliases(property_value) + else + authenticatorToReturn[key_mapping] = Base64.strict_encode64(property_value) + end + end + results << authenticatorToReturn + end + rescue => e + raise ApplicationController::InternalServerError, e.message + end + # the authenticators are sorted by resource_id DESC + results + end + + private + + def build_enforced_claims(value) + # enforced-claims should be a array of strings seperated by comma + if value == "" + # if the value of enforced-claims is empty string it means it declared on the policy but never set a value. + # in such a case, by design, we need to return empty array. + return [] + else + result = split_string(value) + result = result.map { |item| Base64.strict_encode64(item) } + result + end + end + + def split_string(input) + # Split the string by comma + result = input.split(',', -1).map(&:strip) + + result + end + + def build_claim_aliases(value) + # claim aliases should be a array of objects with "annotationName" : "claimName" structure + if value == "" + # if the value of claim aliases is empty string it means it declared on the policy but never set a value. + # in such a case, by design, we need to return empty array. + return [] + else + begin + result = value.split(',', -1).map do |pair| + annotation, claim = pair.split(':', 2) + { + "annotationName" => Base64.strict_encode64(annotation.to_s.strip), + "claimName" => Base64.strict_encode64(claim.to_s.strip) + } + end + rescue => e + raise ApplicationController::InternalServerError, e.message + end + result + end + end + +end diff --git a/app/domain/edge_logic/data_handlers/install_handler.rb b/app/domain/edge_logic/data_handlers/install_handler.rb new file mode 100644 index 0000000000..e1ca0290b2 --- /dev/null +++ b/app/domain/edge_logic/data_handlers/install_handler.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module EdgeLogic + module DataHandlers + class InstallHandler + def initialize(logger) + @logger = logger + @error_message = nil + end + + def call(params, hostname, ip) + edge = nil + begin + edge = Edge.get_by_hostname(hostname) + installation_date = params.require(:installation_date) + InstallHandler.update_installation_date(edge, installation_date) + rescue => e + @error_message = e.message + raise e + ensure + audit_installed(edge&.name, ip) + end + end + + def self.update_installation_date(edge, installation_date) + edge.update(installation_date: Time.at(installation_date)) + end + + private + + def audit_installed(edge_name = "not-found", ip) + audit_params = { edge_name: edge_name, user: edge_name, client_ip: ip } + audit_params[:error_message] = @error_message if @error_message + Audit.logger.log(Audit::Event::EdgeStartup.new( + **audit_params + )) + end + end + end +end diff --git a/app/domain/edge_logic/data_handlers/ongoing_handler.rb b/app/domain/edge_logic/data_handlers/ongoing_handler.rb new file mode 100644 index 0000000000..86873e97e1 --- /dev/null +++ b/app/domain/edge_logic/data_handlers/ongoing_handler.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module EdgeLogic + module DataHandlers + class OngoingHandler + + include ParamsValidator + def initialize(logger) + @logger = logger + end + + def call(params, hostname, ip) + params.require(:edge_statistics).require(:last_synch_time) + allowed_params = [:edge_version, :edge_container_type, :edge_proxy, edge_statistics: [:last_synch_time, cycle_requests: + [:get_secret, :apikey_authenticate, :jwt_authenticate, :redirect]]] + options = params.permit(*allowed_params).to_h + validate_params(options, input_validator) + + edge = Edge.get_by_hostname(hostname) + InstallHandler.update_installation_date(edge, -1) unless edge.installation_date + edge.record_edge_access(options, ip) + # Log Edge statistics to be collected by Datadog + stats = options['edge_statistics'] + cycle_reqs = stats['cycle_requests'] || {} + # Log whether edge uses proxy + edge_proxy = options['edge_proxy'] + edge_proxy_usage = "" + if edge_proxy + edge_proxy_usage = " using edge proxy" + end + #convert time to seconds + last_synch_time_sec = Rational(stats['last_synch_time'], 1000) + installation_time_sec = Rational(edge.installation_date, 1000) + @logger.info(LogMessages::Edge::EdgeTelemetry.new(tenant_id, edge.name, Time.at(last_synch_time_sec), + cycle_reqs['get_secret'], cycle_reqs['apikey_authenticate'], + cycle_reqs['jwt_authenticate'], cycle_reqs['redirect'], + edge.version, edge.platform, Time.at(installation_time_sec), edge_proxy_usage)) + end + + def input_validator + @input_validator ||= ->(k, v) { + case k.to_sym + when :edge_version + v.match?(/^[0-9.v]+$/) + when :edge_container_type + %w[podman docker].include?(v.downcase) + when :edge_proxy + [true, false].include?(v) + when :last_synch_time, :cycle_requests, :get_secret, :apikey_authenticate, :jwt_authenticate, :redirect + numeric_validator.call(k, v) + else + numeric_validator.call(k, v) || string_length_validator.call(k, v) + end + } + end + + private + + def tenant_id + Rails.application.config.conjur_config.tenant_id + end + + end + end +end diff --git a/app/domain/edge_logic/replication_handler.rb b/app/domain/edge_logic/replication_handler.rb new file mode 100644 index 0000000000..0e370f11d8 --- /dev/null +++ b/app/domain/edge_logic/replication_handler.rb @@ -0,0 +1,124 @@ +module ReplicationHandler + include Secrets::RedisHandler + + def replicate_hosts(scope) + results = [] + roles_with_creds = scope.eager(:credentials) + hosts = Role.roles_with_annotations(roles_with_creds).all + hosts.each do |host| + host_to_return = {} + host_to_return[:id] = host[:role_id] + host_api_key = host.api_key + if host_api_key.nil? + host_to_return[:api_key] = "" + host_to_return[:salt] = "" + else + salt = OpenSSL::Random.random_bytes(32) + host_to_return[:api_key] = Base64.strict_encode64(hmac_api_key(host.api_key, salt)) + host_to_return[:salt] = Base64.strict_encode64(salt) + end + membership_proc = Proc.new do + all_roles = host.all_roles + if Rails.application.config.conjur_config.try(:conjur_edge_is_atlantis) + # Filter out memberships to host, which probably indicates ownership rather than real membership + all_roles = all_roles.where(~:role_id.like('%:host:%')) + end + all_roles.all.select { |h| h[:role_id] != (host[:role_id]) } + end + host_to_return[:memberships] = Rails.application.config.conjur_config.try(:conjur_edge_is_atlantis) ? + get_role_membership(host[:role_id], &membership_proc) : membership_proc.call + host_to_return[:annotations] = host[:annotations] == "[null]" ? [] : JSON.parse(host[:annotations]) + results << host_to_return + end + results + end + + def replicate_secrets(limit, offset, options, accepts_base64, selective_enabled) + variables = build_variables_map(limit, offset, options, selective_enabled) + construct_variable(variables, accepts_base64, selective_enabled) + end + + def replicate_single_secret(id, accepts_base64, selective_enabled) + # limit = 1 and offset = 0 are used to get the latest version of the secret + # If the secret has multiple versions, the latest version is returned + limit = 1 + offset = 0 + variables = build_variables_map(limit, offset, nil, selective_enabled, id: id) + construct_variable(variables, accepts_base64, selective_enabled) + end + + private + + def construct_variable(variables, accepts_base64, selective_enabled) + results = [] + failed = [] + + variables.each do |id, variable| + variable_to_return = {} + variable_to_return[:id] = id + variable_to_return[:owner] = variable[:owner_id] + variable_to_return[:permissions] = get_permissions(id, variable, selective_enabled) + secret_value = Slosilo::EncryptedAttributes.decrypt(variable[:value], aad: id) + variable_to_return[:value] = accepts_base64 ? Base64.strict_encode64(secret_value) : secret_value + variable_to_return[:version] = variable[:version] + variable_to_return[:versions] = [] + value = { + "version": variable_to_return[:version], + "value": variable_to_return[:value] + } + variable_to_return[:versions] << value + begin + JSON.generate(variable_to_return) + results << variable_to_return + rescue + failed << { "id": id } + end + end + [results, failed] + end + + def build_variables_map(limit, offset, options, selective_enabled, id: nil) + variable_id = id.nil? ? "'#{options[:account]}:variable:data/%'" : "'#{id}'" + variables = {} + if selective_enabled == "true" + query_string = "SELECT * from allowed_secrets_per_role('#{current_user.id}', #{variable_id}, #{limit}, #{offset})" + else + query_string = "SELECT * FROM secrets JOIN (SELECT resource_id, owner_id FROM resources WHERE (resource_id LIKE #{variable_id}) ORDER BY resource_id LIMIT #{limit} OFFSET #{offset}) AS res ON (res.resource_id = secrets.resource_id)" + end + + Sequel::Model.db.fetch(query_string) do |row| + if variables.key?(row[:resource_id]) + if row[:version] > variables[row[:resource_id]][:version] + variables[row[:resource_id]] = row + end + else + variables[row[:resource_id]] = row + end + end + variables + end + + def get_permissions(id,variable,selective_enabled) + permissions = [] + if selective_enabled == "true" + Sequel::Model.db.fetch("SELECT * from permissions where resource_id='" + variable[:resource_id] + "' AND privilege = 'execute'") do |row| + permission = {} + permission[:privilege] = row[:privilege] + permission[:resource] = row[:resource_id] + permission[:role] = row[:role_id] + permission[:policy] = row[:policy_id] + permissions.append(permission) + end + else + Permission.where(resource_id:id, privilege:'execute').each do |row| + permission = {} + permission[:privilege] = row[:privilege] + permission[:resource] = row[:resource_id] + permission[:role] = row[:role_id] + permission[:policy] = row[:policy_id] + permissions.append(permission) + end + end + return permissions + end +end diff --git a/app/domain/errors.rb b/app/domain/errors.rb index 5018fe910f..ccd1f5d3bf 100644 --- a/app/domain/errors.rb +++ b/app/domain/errors.rb @@ -8,7 +8,43 @@ # # See also ./logs.rb module Errors + module Group + DuplicateMember = ::Util::TrackableErrorClass.new( + msg: "'{0}' (kind='{1}') is already a member of '{2}'", + code: "CONJ00180W" + ) + + ResourceNotMember = ::Util::TrackableErrorClass.new( + msg: "'{0}' (kind='{1}') isn't a member of '{2}'", + code: "CONJ00181W" + ) + end + module Conjur + ParameterMissing = ::Util::TrackableErrorClass.new( + msg: "Missing required parameter: {0}", + code: "CONJ00190W" + ) + + ParameterValueInvalid = ::Util::TrackableErrorClass.new( + msg: "The value in the '{0}' parameter is not valid. Error: {1}", + code: "CONJ00191W" + ) + + ParameterTypeInvalid = ::Util::TrackableErrorClass.new( + msg: "The '{0}' parameter must be of 'type={1}'", + code: "CONJ00192W" + ) + + NumOfParametersInvalid = ::Util::TrackableErrorClass.new( + msg: "The parameter received in the data is not valid. Allowed parameters: {0}", + code: "CONJ00193W" + ) + + APIHeaderMissing = ::Util::TrackableErrorClass.new( + msg: "The api belongs to v2 APIs but it missing the version \"application/x.secretsmgr.v2+json\" in the Accept header", + code: "CONJ00194W" + ) RequiredResourceMissing = ::Util::TrackableErrorClass.new( msg: "Missing required resource: {0-resource-name}", @@ -49,14 +85,25 @@ module Conjur ) ApiKeyNotFound = ::Util::TrackableErrorClass.new( - msg: "Role '{0-role}' API key not found", - code: "CONJ00121E" + msg: "Role '{0-role}' does not have an API key", + code: "CONJ00170E", + base_error_class: ::Exceptions::MethodNotAllowed ) RequestedResourceNotFound = ::Util::TrackableErrorClass.new( msg: "Resource '{0-resource}' requested by role '{1-role}' not found", code: "CONJ00123E" ) + + FailedRotateSlosilo = ::Util::TrackableErrorClass.new( + msg: "Failed to rotate Sloislo key. Reason: '{0}'", + code: "CONJ00157E" + ) + + DuplicateVariable = ::Util::TrackableErrorClass.new( + msg: "The request contains duplicated variable ids: {0}.", + code: "CONJ00160E" + ) end module Authorization @@ -65,6 +112,11 @@ module Authorization code: "CONJ00125E" ) + EndpointNotVisibleToRole = ::Util::TrackableErrorClass.new( + msg: "The requested endpoint is forbidden. Reason: '{0}'", + code: "CONJ00151E" + ) + AccessToResourceIsForbiddenForRole = ::Util::TrackableErrorClass.new( msg: "Role '{0-role}' does not have permissions to access the requested resource '{1-resource}'", code: "CONJ00122E" @@ -161,6 +213,11 @@ module Security code: "CONJ00006E" ) + RoleNotAuthorizedOnPolicyDescendants = ::Util::TrackableErrorClass.new( + msg: "'{0-role-name}' does not have '{1-privilege}' privilege on some children of {2-policy-name}", + code: "CONJ00136E" + ) + RoleNotFound = ::Util::TrackableErrorClass.new( msg: "'{0-role-name}' not found", code: "CONJ00007E" @@ -175,6 +232,12 @@ module Security msg: "'{0-role}' matched multiple roles", code: "CONJ00009E" ) + + UnauthorizedSnsRoleCreds = ::Util::TrackableErrorClass.new( + msg: "Unauthorized role or expired credentials for sns topic", + code: "CONJ00010E" + ) + end module RequestBody @@ -237,7 +300,7 @@ module Jwt module AuthnOidc IdTokenClaimNotFoundOrEmpty = ::Util::TrackableErrorClass.new( msg: "Claim '{0-claim-name}' not found or empty in ID token. " \ - "This claim is defined in the id-token-user-property variable.", + "This claim is defined in the {1-config-var} variable.", code: "CONJ00013E" ) @@ -265,6 +328,16 @@ module AuthnOidc msg: "Access Token retrieval failure: '{0-error}'", code: "CONJ00133E" ) + + InvalidCertificate = ::Util::TrackableErrorClass.new( + msg: "Invalid certificate: {0-message}", + code: "CONJ00135E" + ) + + NonceVerificationFailed = ::Util::TrackableErrorClass.new( + msg: "Provided nonce does not match the returned nonce", + code: 'CONJ00153E' + ) end module AuthnIam @@ -414,6 +487,26 @@ module AuthnK8s msg: "Unable to establish Kubernetes client to execute method: \#{0}", code: "CONJ00132E" ) + + InvalidServiceAccountToken = ::Util::TrackableErrorClass.new( + msg: "Invalid service account token: {0}", + code: "CONJ00153E" + ) + + InvalidApiCert = ::Util::TrackableErrorClass.new( + msg: "Invalid Kubernetes API CA certificate: {0}", + code: "CONJ00154E" + ) + + InvalidSigningCert = ::Util::TrackableErrorClass.new( + msg: "Invalid signing certificate: {0}", + code: "CONJ00155E" + ) + + InvalidSigningKey = ::Util::TrackableErrorClass.new( + msg: "Invalid signing key: {0}", + code: "CONJ00156E" + ) end module AuthnAzure @@ -738,4 +831,21 @@ module Util code: "CONJ00044E" ) end + + module Monitoring + + InvalidOrMissingMetricType = ::Util::TrackableErrorClass.new( + msg: "Invalid or missing metric type: {0-metric-type}", + code: "CONJ00152E" + ) + end + + module Edge + + MaxEdgeAllowedNotFound = ::Util::TrackableErrorClass.new( + msg: "max-edge-allowed secret doesn't exist. This might indicate that Edge was not enabled for this tenant", + code: "CONJ00169E", + base_error_class: Exceptions::RecordNotFound + ) + end end diff --git a/app/domain/factories/Readme.md b/app/domain/factories/Readme.md new file mode 100644 index 0000000000..faef218233 --- /dev/null +++ b/app/domain/factories/Readme.md @@ -0,0 +1,993 @@ +# Policy Factory + +## Setup + +The easiest way to load Policy Factories into Conjur is via: + +- Internal [Cyberark Policy Factory CLI](https://github.cyberng.com/jvanderhoof/policy-factory-cli) +- External [Mirror](https://github.com/jvanderhoof/policy_factory) + +The CLI provides a set of Factories as well as a DSL for writing your own Factories. + +## API + +All Policy Factory API endpoints require authentication and follow the existing Conjur API patterns. Policy Factories are stored in Conjur variables. Conjur Policy should be used to +limit access to particular Factories to those roles which are allowed to use them. + +### List Factories + +Display all available Factories a role has access to, grouped by factory classification: + +``` +GET /factories/ +``` + +#### Sample Response + +```json +{ + "authenticators": [ + { + "name": "authn_iam", + "namespace": "authenticators", + "full-name": "authenticators/authn_iam", + "current-version": "v1", + "description": "Create a new Authn-IAM authenticator" + }, + { + "name": "authn_jwt_jwks", + "namespace": "authenticators", + "full-name": "authenticators/authn_jwt_jwks", + "current-version": "v1", + "description": "Create a new Authn-JWT Authenticator using a JWKS endpoint" + }, + { + "name": "authn_jwt_public_key", + "namespace": "authenticators", + "full-name": "authenticators/authn_jwt_public_key", + "current-version": "v1", + "description": "Create a new Authn-JWT Authenticator that validates using a public key" + }, + { + "name": "authn_oidc", + "namespace": "authenticators", + "full-name": "authenticators/authn_oidc", + "current-version": "v1", + "description": "Create a new Authn-OIDC Authenticator" + } + ], + "connections": [ + { + "name": "database", + "namespace": "connections", + "full-name": "connections/database", + "current-version": "v1", + "description": "All information for connecting to a database" + } + ], + "core": [ + { + "name": "grant", + "namespace": "core", + "full-name": "core/grant", + "current-version": "v1", + "description": "Assigns a Role to another Role" + }, + { + "name": "group", + "namespace": "core", + "full-name": "core/group", + "current-version": "v1", + "description": "Creates a Conjur Group" + }, + { + "name": "managed_policy", + "namespace": "core", + "full-name": "core/managed_policy", + "current-version": "v1", + "description": "Policy with an owner group" + }, + { + "name": "policy", + "namespace": "core", + "full-name": "core/policy", + "current-version": "v1", + "description": "Creates a Conjur Policy" + }, + { + "name": "user", + "namespace": "core", + "full-name": "core/user", + "current-version": "v1", + "description": "Creates a Conjur User" + } + ] +} +``` + +#### Response Codes + +| Code | Description | +|-|-| +| 200 | Factories returned as a JSON list | +| 401 | The request lacks valid authentication credentials | +| 403 | The authenticated role lacks the necessary privilege | + +### View + +View the details of a particular Factory + +``` +GET /factories/// +``` + +#### Sample Response + +```json +{ + "title": "Authn-IAM Template", + "version": "v1", + "description": "Create a new Authn-IAM authenticator", + "properties": { + "id": { + "description": "Resource Identifier", + "type": "string" + }, + "annotations": { + "description": "Additional annotations", + "type": "object" + } + }, + "required": [ + "id" + ] +} +``` + +#### Response Codes + +| Code | Description | +|-|-| +| 200 | Factory details returned as JSON | +| 401 | The request lacks valid authentication credentials | +| 403 | The authenticated role lacks the necessary privilege | +| 404 | The factory does not exist, or it has not been set | + + +### Create with a Policy Factory + +Create resources using a Factory + +``` +POST /factory-resources//// +``` + +#### Sample Request + +``` +# POST /factories/demo/connections/database + +{ + "id": "myapp-database", + "branch": "root", + "variables": { + "url": "https://foo.bar.baz.com", + "port": "5432", + "username": "myapp", + "password": "supersecretP@ssW0rd" + } +} +``` + +#### Sample Response + +```json +{ + "created_roles": {}, + "version": 1 +} +``` + +#### Response Codes + +| Code | Description | +|-|-| +| 201 | Policy and variables were set successfully | +| 400 | Request body is invalid (missing fields, malformed, etc.) | +| 401 | Policy creation or variable setting not permitted | +| 403 | The authenticated role lacks the necessary privilege to use the factory | +| 404 | The factory does not exist, or it has not been set | + + +### View Factory-created Resources + +View the resources created by a Policy Factory. + +*Note: Only the results of a complex Policy Factory are shown using this endpoint. When +creating Conjur primitives, use the roles/resources API.* + +``` +GET /factory-resources// +``` + +#### Sample Request + +Assuming a Database connection was created using the following: + +```json +{ + "id": "myapp-database", + "branch": "my-databases/production", + "annotations": { + "foo": "bar", + "baz": "bang" + }, + "variables": { + "type": "mysql", + "url": "https://foo.bar.baz.com", + "port": "3306", + "username": "myapp", + "password": "supersecretP@ssW0rd" + } +} +``` + +``` +# GET /factory-resources/demo/my-databases%2Fproduction +``` + +#### Sample Response + +```json +{ + "id": "my-databases/production/myapp-database", + "variables": { + "type": { + "value": "mysql", + "description": "Database Type" + }, + "url": { + "value": "https://foo.bar.baz.com", + "description": "Database URL" + }, + "port": { + "value": "3306", + "description": "Database Port" + }, + "username": { + "value": "myapp", + "description": "Database Username" + }, + "password": { + "value": "supersecretP@ssW0rd", + "description": "Database Password" + }, + "ssl-certificate": { + "value": null, + "description": "Client SSL Certificate" + }, + "ssl-key": { + "value": null, + "description": "Client SSL Key" + }, + "ssl-ca-certificate": { + "value": null, + "description": "CA Root Certificate" + } + }, + "annotations": { + "foo": "bar", + "baz": "bang" + }, + "details": { + "classification": "connections", + "version": "v1", + "identifier": "database" + } +} +``` + +#### Response Codes + +| Code | Description | +|-|-| +| 200 | Factory resource details returned as JSON | +| 401 | The request lacks valid authentication credentials | +| 404 | The policy does not exist or if the policy was not created by a Factory | + +### View all Factory-created Resources + +Given a role, return all the Factory-created resources the role has access to. + +*Note: Only the results of a complex Policy Factory are shown using this endpoint. When +creating Conjur primitives, use the roles/resources API.* + +``` +GET /factory-resources/ +``` + +#### Sample Request + +``` +GET /factory-resources/demo +``` + +#### Sample Response + +```json +[ + { + "id": "my-test-policy-1/production-1", + "variables": { + "type": { + "value": "mysql", + "description": "Database Type" + }, + "url": { + "value": "https://foo.bar.baz.com", + "description": "Database URL" + }, + "port": { + "value": "5432", + "description": "Database Port" + }, + "username": { + "value": "foo-bar", + "description": "Database Username" + }, + "password": { + "value": "bar-baz", + "description": "Database Password" + }, + "ssl-certificate": { + "value": null, + "description": "Client SSL Certificate" + }, + "ssl-key": { + "value": null, + "description": "Client SSL Key" + }, + "ssl-ca-certificate": { + "value": null, + "description": "CA Root Certificate" + } + }, + "annotations": {}, + "details": { + "classification": "connections", + "version": "v1", + "identifier": "database" + } + }, + { + "id": "production-2", + "variables": { + "type": { + "value": "mysql", + "description": "Database Type" + }, + "url": { + "value": "https://foo.bar.baz.com", + "description": "Database URL" + }, + "port": { + "value": "5432", + "description": "Database Port" + }, + "username": { + "value": "foo-bar", + "description": "Database Username" + }, + "password": { + "value": "bar-baz", + "description": "Database Password" + }, + "ssl-certificate": { + "value": null, + "description": "Client SSL Certificate" + }, + "ssl-key": { + "value": null, + "description": "Client SSL Key" + }, + "ssl-ca-certificate": { + "value": null, + "description": "CA Root Certificate" + } + }, + "annotations": { + "foo": "bar", + "baz": "bang" + }, + "details": { + "classification": "connections", + "version": "v1", + "identifier": "database" + } + } +] +``` + +#### Response Codes + +| Code | Description | +|-|-| +| 200 | Factory resource details returned as JSON array | +| 401 | The request lacks valid authentication credentials | + + +### [Experimental] Circuit Breakers + +Policy Factories includes a "circuit-breaker" group, which allows access to variables +or use of an authenticator to be severed. This allows a security administrator to mitigate +a data leak or security event without writing any Conjur Policy. + +Factories created via the CLI (starting with version `1.0.0`) automatically include the +required policy. + +*Note: enabling/disabling circuit-breakers requires `update` permission on the Factory-created +policy.* + +### [Experimental] Cut Access + +Removes read access to Factory variables or authenticate permission on an authenticator. + +``` +POST /factory-resources///disable +``` + +#### Sample Request + +``` +POST /factory-resources/demo/my-test-policy-1%2Fproduction-1/disable +``` + +![Factory Setup](./images/factory-setup.png) + +```plantuml +@startuml factory-setup +start +:Step into running container; +if ("Conjur Enterprise?") then (yes) + :Run `evoke install factories --account `; +else (no) + :Run `conjurctl install factories --account `; +endif +partition "Installation (run as `admin`)" { + :Apply Factory base policy; + :Load each Factory into its\ncorresponding versioned variable; +} +:Verify factories are available via `/factories/`; +@enduml +``` + +## Factory Upgrade + +Upgrades will follow the following workflow: + +![Factory Upgrade](./images/factory-upgrade.png) + +```plantuml +@startuml factory-upgrade +start +:Step into running container; +if ("Conjur Enterprise?") then (yes) + :Run `evoke install factories --account `; +else (no) + :Run `conjurctl install factories --account `; +endif +partition "Installation (run as `admin`)" { + :Apply Factory base policy with new factory versions; + :Load each Factory into its\ncorresponding versioned variable; +} +:Verify factories are available via `/factories/`; +@enduml +``` + +## View all Policy Factories + +A role is limited to viewing the Factories they have permission (`execute`) to see. +If a role can see a factory, they will be able to see errors in mis-configured Factories. + +![Factory List Request](./images/factory-list-request.png) + +```plantuml +@startuml factory-list-request +start +:Identify target Factory based on request params; +:Gather factories the role is able to view; +partition "For each Factory Version" { + repeat + if ("Factory is present?") then (yes) + if ("Is Factory format is valid?") then (yes) + if ("Is Factory Schema is valid?") then (yes) + :Display Factory details and Schema; + else + #pink:[Error] Invalid Factory Schema; + endif + else + #pink:[Error] Invalid Factory Format; + endif + else + #pink:[Error] Factory not Defined; + endif + backward: Next Factory; + repeat while (More Factories?) +} +:Return JSON Summary; +@enduml +``` + +## Policy Factory Info Requests + +![Factory Info Request](./images/factory-info-request.png) + +```plantuml +@startuml factory-info-request +(*) --> "Identify target Factory based on request params" +if "Does Factory exist?" then + --> [yes] if "Role has permission to view factory" then + --> [yes] if "Factory is present?" then + --> "Load Factory" + --> [yes] if "Factory format is valid?" then + --> [yes] if "Factory Schema is valid?" then + --> "Return Schema" + else + --> [no] "[Error] Invalid Factory Schema" + endif + else + --> [no] "[Error] Invalid Factory Format" + endif + else + --> [no] "[Error] Factory not Defined" + endif + else + --> [no] "[Error] Factory not Available" + endif +else + --> [no] "[Error] Factory not Found" +endif +@enduml + +``` + +## Policy Factory Creation Requests + +![Factory Create Request](./images/factory-create-request.png) + +```plantuml +@startuml factory-create-request +(*) --> "Identify Factory variable based on request params" +if "Does factory variable exist?" then + --> [yes] if "Can role load factory?" then + --> [yes] "Load Factory" + --> [yes] if "Does factory variable have a value?" then + --> [yes] if "Factory format is valid?" then + --> [yes] if "Factory Schema is valid?" then + --> "Extract Schema from Factory Variable" + --> "Parse [POST] JSON Request body" + --> if "is JSON valid?" + --> [yes] if "Required keys present?" + --> [yes] if "Required values present?" + --> [yes] if "Policy rendered successfully?" + --> [yes] if "Policy namespace path rendered successfully?" + --> [yes] if "Policy successfully applied" + --> [yes] if "Factory has variables?" + --> [yes] if "Variables set successfully set?" + --> "Return Policy and Variable response" + ' note left + ' Response Code: 200 + ' Response: {"response": { + ' "code": 200, + ' "created_resources": [ + ' "::", + ' {":host:": {"api_key": ""}}, + ' ":variable:" + ' ] + ' }} + ' end note + --> (*) + else + --> [no] "[Error] Setting Variable(s) not Permitted" + ' note right + ' Response Code: 401 + ' Response {"error": { + ' "code": 401, + ' "error": "Role is not permitted to set the following secrets in this factory: 'secret-1', 'secret-2'", + ' "fields": [ + ' "secret-1", + ' "secret-2" + ' ] + ' }} + ' Log Level: Error + ' Log Message: Role '' is not permitted to create the following factory variables the '': 'secret-1', 'secret-2' + ' end note + endif + else + --> [no] " Policy Created Response" + ' note left + ' Response Code: 200 + ' Response: {"response": { + ' "code": 200, + ' "created_resources": [ + ' "::", + ' {":host:": {"api_key": ""}} + ' ] + ' }} + ' end note + endif + else + --> [no] "[Error] Policy Creation not Permitted" + ' note left + ' Response Code: 401 + ' Response {"error": { + ' "code": 401, + ' "error": "Role is not permitted to create a factory in this policy" + ' }} + ' Log Level: Error + ' Log Message: Role '' is not permitted to create a factory in the '' + ' end note + endif + else + --> [no] "[Error] Invalid Factory Namespace ERB" + ' note left + ' Response Code: 400 + ' Response: {"error": { + ' "code": 400, + ' "error": "Policy Factory Namespace Template contains invalid ERB" + ' }} + ' Log Level: Error + ' Log Message Policy Factory 'conjur/factories/core/' Namespace Template contains invalid ERB + ' end note + endif + else + --> [no] "[Error] Invalid Factory Policy ERB" + ' note left + ' Response Code: 400 + ' Response: {"error": { + ' "code": 400, + ' "error": "Policy Factory Policy Template contains invalid ERB" + ' }} + ' Log Level: Error + ' Log Message Policy Factory 'conjur/factories/core/' Policy Template contains invalid ERB + ' end note + endif + else + --> [no] "[Error] Missing Required Values" + ' note left + ' Response Code: 400 + ' Response: {"error": { + ' "code": 400, + ' "message": "The following fields are missing values: 'field-1', 'field-2'", + ' "fields": [ + ' {"field-1": { "error": "cannot be empty" }}, + ' {"field-2": { "error": "cannot be empty" }} + ' ]}} + ' Log Level: Error + ' Log Message: The following fields are missing values in the request JSON body: 'field-1', 'field-2' + ' end note + endif + else + --> [no] "[Error] Missing Required Keys" + ' note left + ' Response Code: 400 + ' Response: {"error": { + ' "code": 400, + ' "message": "The following fields are missing: 'field-1', 'field-2'", + ' "fields": [ + ' {"field-1": { "error": "must be present" }}, + ' {"field-2": { "error": "must be present" }} + ' ] + ' }} + ' Log Level: Error + ' Log Message: The following fields are missing from the request JSON body: 'field-1', 'field-2' + ' end note + endif + else + --> [no] "[Error] Bad Request Body" + ' note left + ' Response Code: 400 + ' Response: {"error": { + ' "code": 400, + ' "message": "Request JSON contains invalid syntax" + ' }} + ' Log Level: Error + ' Log Message: Request JSON contains invalid syntax + ' end note + endif + else + --> [no] "[Error] Invalid Factory Schema" + endif + else + --> [no] "[Error] Invalid Factory Format" + endif + else + --> [no] "[Error] Factory not Defined" + ' note left + ' Response Code: 400 + ' Response: {"error": { + ' "code": 400, + ' "resource": "conjur/factories/core/", + ' "message": "Requested Policy Factory is empty" + ' }} + ' Log Level: Error + ' Log Message: Policy Factory Variable "conjur/factories/core/" is empty + ' end note + endif + else + --> [no] "[Error] Factory not Available" + ' note left + ' Response Code: 403 + ' Response: {"error": { + ' "code": 403, + ' "resource": "core/", + ' "message": "Factory is not available" + ' }} + ' Log Level: Error + ' Log Message: Policy Factory "core/" is not available + ' end note + endif +else + --> [no] "[Error] Factory not Found" + ' note left + ' Response Code: 404 + ' Response: {"error": { + ' "code": 404, + ' "resource": "conjur/factories/core/", + ' "message": "Requested Policy Factory does not exist" + ' }} + ' Log Level: Error + ' Log Message: Policy Factory Variable "conjur/factories/core/" does not exist + ' end note +endif +@enduml +``` + +## Policy Factory Creation Requests (beta) + +![Policy Factory Create Request](./images/Readme-5.png) + +```plantuml +@startuml +start +:Identify Factory\nvariable based\non request params; +if (Does factory variable exist?) then (yes) + if (Can role load factory variable?) then (yes) + if (Does factory variable have a value?) then (yes) + :Load Factory; + :Extract Schema from Factory Variable; + :Parse [POST] JSON Request body; + ' :Extract Schema from Factory; + if (Parse JSON body?) then (yes) + if (Required keys missing?) then (no) + #pink: Missing Keys; + ' note right + ' Response Code: 400 + ' Response: {"error": { + ' "code": 400, + ' "message": "The following fields are missing: 'field-1', 'field-2'", + ' "fields": [ + ' {"field-1": { "error": "must be present" }}, + ' {"field-2": { "error": "must be present" }} + ' ] + ' }} + ' Log Level: Error + ' Log Message: The following fields are missing from the request JSON body: 'field-1', 'field-2' + ' end note + kill + else (yes) + if (required values empty?) then (no) + #pink: Missing Values; + ' note right + ' Response Code: 400 + ' Response: {"error": { + ' "code": 400, + ' "message": "The following fields are missing values: 'field-1', 'field-2'", + ' "fields": [ + ' {"field-1": { "error": "cannot be empty" }}, + ' {"field-2": { "error": "cannot be empty" }} + ' ]}} + ' Log Level: Error + ' Log Message: The following fields are missing values in the request JSON body: 'field-1', 'field-2' + ' end note + kill + else (yes) + if (Policy rendered?) then (yes) + if (Policy namespace path rendered?) then (yes) + if (Policy successfully applied) then (yes) + if (Factory has variables?) then (yes) + if (Variable successfully set?) then (yes) + #lightgreen: Return policy response; + ' note right + ' Response Code: 200 + ' Response: {"response": { + ' "code": 200, + ' "created_resources": [ + ' "::", + ' {":host:": {"api_key": ""}}, + ' ":variable:" + ' ] + ' }} + ' end note + end + else (no) + #pink: Setting Variable(s) not Permitted; + ' note right + ' Response Code: 401 + ' Response {"error": { + ' "code": 401, + ' "error": "Role is not permitted to set the following secrets in this factory: 'secret-1', 'secret-2'", + ' "fields": [ + ' "secret-1", + ' "secret-2" + ' ] + ' }} + ' Log Level: Error + ' Log Message: Role '' is not permitted to create the following factory variables the '': 'secret-1', 'secret-2' + ' end note + kill + endif + else (no) + #lightgreen: Return policy response; + ' note right + ' Response Code: 200 + ' Response: {"response": { + ' "code": 200, + ' "created_resources": [ + ' "::", + ' {":host:": {"api_key": ""}} + ' ] + ' }} + ' end note + kill + endif + else (no) + #pink: Policy Creation not Permitted; + ' note right + ' Response Code: 401 + ' Response {"error": { + ' "code": 401, + ' "error": "Role is not permitted to create a factory in this policy" + ' }} + ' Log Level: Error + ' Log Message: Role '' is not permitted to create a factory in the '' + ' end note + kill + endif + else (no) + #pink: Invalid Policy Namespace ERB; + ' note right + ' Response Code: 400 + ' Response: {"error": { + ' "code": 400, + ' "error": "Policy Factory Namespace Template contains invalid ERB" + ' }} + ' Log Level: Error + ' Log Message Policy Factory 'conjur/factories/core/' Namespace Template contains invalid ERB + ' end note + kill + endif + else (no) + #pink: Invalid Policy ERB; + ' note right + ' Response Code: 400 + ' Response: {"error": { + ' "code": 400, + ' "error": "Policy Factory Policy Template contains invalid ERB" + ' }} + ' Log Level: Error + ' Log Message Policy Factory 'conjur/factories/core/' Policy Template contains invalid ERB + ' end note + kill + endif + endif + endif + else (no) + #pink: Malformed JSON; + ' note right + ' Response Code: 400 + ' Response: {"error": { + ' "code": 400, + ' "message": "Request JSON contains invalid syntax" + ' }} + ' Log Level: Error + ' Log Message: Request JSON contains invalid syntax + ' end note + kill + endif + else (no) + #pink: Factory Variable empty; + ' note right + ' Response Code: 400 + ' Response: {"error": { + ' "code": 400, + ' "resource": "conjur/factories/core/", + ' "message": "Requested Policy Factory is empty" + ' }} + ' Log Level: Error + ' Log Message: Policy Factory Variable "conjur/factories/core/" is empty + ' end note + kill + endif + else (no) + #pink: Factory not available; + ' note right + ' Response Code: 403 + ' Response: {"error": { + ' "code": 403, + ' "resource": "core/", + ' "message": "Factory is not available" + ' }} + ' Log Level: Error + ' Log Message: Policy Factory "core/" is not available + ' end note + kill + endif +else (no) + #pink: Factory Variable not present; + ' note right + ' Response Code: 404 + ' Response: {"error": { + ' "code": 404, + ' "resource": "conjur/factories/core/", + ' "message": "Requested Policy Factory does not exist" + ' }} + ' Log Level: Error + ' Log Message: Policy Factory Variable "conjur/factories/core/" does not exist + ' end note + kill +endif +@enduml +``` + +### UI Workflow + +![UI Factory Setup](./images/factory-setup.png) + +```plantuml +@startuml factory-setup +start +:Login; +:Navigate to "Policy Factories" page; +if (Can view Factories) then (yes) + :Show Factory Groupings; + :Navigate to Factory Grouping; + :Select a Factory; + if ("Can view Factory") then (yes) + :View Factory form; + if ("Factory successfully created") then (yes) + :Redirect + else + end + else + end +else (no) + :Show empty Factories page\nwith "No Factories Available"; +end +@enduml +``` + +## Code Architecture + +![Basic Overview](./images/Basic-Sample.png) + +```plantuml +@startuml Basic Sample +!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Component.puml + +Component(controller, "PolicyFactoryController", "Rails", "Routes requests to Business Logic and renders results") + +Component(repository, "PolicyFactoryRepository", "Ruby", "Retrieves Factories from Conjur Variables") + +Component(data_object, "DataObjects::PolicyFactory", "Ruby") + +Component(create, "CreateFromPolicyFactory", "Ruby", "Generates Conjur elements using a Policy Factory") + +Rel(repository, controller, "loads factory from") + +' Component(repository 'PolicyFactoryRepository') + +' component PolicyFactoryController +' component PolicyFactoryRepository +@enduml +``` diff --git a/app/domain/factories/create_from_policy_factory.rb b/app/domain/factories/create_from_policy_factory.rb new file mode 100644 index 0000000000..0e7af002d2 --- /dev/null +++ b/app/domain/factories/create_from_policy_factory.rb @@ -0,0 +1,207 @@ +# frozen_string_literal: true + +require 'rest_client' +require 'json_schemer' +require 'factories/renderer' + +module Factories + class Utilities + def self.filter_input(str) + str.gsub(/[^0-9a-z\-_]/i, '') + end + end + class CreateFromPolicyFactory + def initialize(renderer: Factories::Renderer.new, http: RestClient, schema_validator: JSONSchemer, utilities: Factories::Utilities) + @renderer = renderer + @http = http + @schema_validator = schema_validator + @utilities = utilities + @logger = Rails.logger + + # JSON and URI are defined here for visibility. They are not currently + # mocked in testing, thus, we're not setting them in the initializer. + @json = JSON + @uri = URI + + # Defined here for visibility. We shouldn't need to mock these. + @success = ::SuccessResponse + @failure = ::FailureResponse + end + + def call(factory_template:, request_body:, account:, authorization:) + validate_and_transform_request( + schema: factory_template.schema, + params: request_body + ).bind do |body_variables| + # Convert `dashed` keys to `underscored`. This only occurs for top-level parameters. + # Conjur variables should be use dashes rather than underscores. + # Filter non-alpha-numeric, dash or underscore characters from inputs values (to prevent injection attacks). + template_variables = body_variables + .transform_keys { |key| key.to_s.underscore } + .each_with_object({}) do |(key, value), rtn| + # Only strip values that are rendered in the policy (not Conjur secret values) + rtn[key] = if key == 'variables' + value + elsif value.is_a?(Hash) + value.transform_values { |internal_value| @utilities.filter_input(internal_value.to_s) } + else + @utilities.filter_input(value.to_s) + end + end + + # Add empty `annotations` hash unless they've previously been set + template_variables["annotations"] = {} unless template_variables.include?('annotations') + + # Push rendered policy to the desired policy branch + @renderer.render(template: factory_template.policy_branch, variables: template_variables) + .bind do |policy_load_path| + valid_variables = factory_template.schema['properties'].keys - ['variables'] + render_and_apply_policy( + policy_load_path: policy_load_path, + policy_template: factory_template.policy, + variables: template_variables.select { |k, _| valid_variables.include?(k) }, + account: account, + authorization: authorization + ).bind do |result| + return @success.new(result) unless factory_template.schema['properties'].key?('variables') + + # Set Policy Factory variables + variables_path = ["<%= id %>"] + # If the variables are headed for the "root" namespace, we don't want the namespace in the path + variables_path.prepend(factory_template.policy_branch) unless policy_load_path == 'root' + @renderer.render(template: variables_path.join('/'), variables: template_variables) + .bind do |variable_path| + set_factory_variables( + schema_variables: factory_template.schema['properties']['variables']['properties'], + factory_variables: template_variables['variables'], + variable_path: variable_path, + authorization: authorization, + account: account + ) + end + .bind do + # If variables were added successfully, return the result so that + # we send the policy load response back to the client. + @success.new(result) + end + end + end + end + end + + private + + def validate_and_transform_request(schema:, params:) + return @failure.new('Request body must be JSON', status: :bad_request) if params.blank? + + begin + params = @json.parse(params) + rescue + return @failure.new('Request body must be valid JSON', status: :bad_request) + end + + # Strip keys without values + params = params.select{|_, v| v.present? } + validator = @schema_validator.schema(schema) + return @success.new(params) if validator.valid?(params) + + errors = validator.validate(params).map do |error| + case error['type'] + when 'required' + missing_attributes = error['details']['missing_keys'].map{|key| [ error['data_pointer'], key].reject(&:empty?).join('/') } #.join("', '") + missing_attributes.map do |attribute| + { + message: "A value is required for '#{attribute}'", + key: attribute + } + end + else + { + message: "Validation error: '#{error['data_pointer']}' must be a #{error['type']}" + } + end + end + @failure.new(errors.flatten, status: :bad_request) + end + + def render_and_apply_policy(policy_load_path:, policy_template:, variables:, account:, authorization:) + @renderer.render( + template: policy_template, + variables: variables + ).bind do |rendered_policy| + @logger.debug{"Policy Factory is applying the following policy to '/policies/#{account}/policy/#{policy_load_path}'"} + @logger.debug{"\n#{rendered_policy}"} + begin + response = @http.post( + "http://localhost:#{ENV['PORT']}/policies/#{account}/policy/#{policy_load_path}", + rendered_policy, + 'Authorization' => authorization + ) + rescue RestClient::ExceptionWithResponse => e + case e.response.code + when 401 + return @failure.new( + { message: 'Authentication failed', + request_error: e.response.body }, status: :unauthorized + ) + when 403 + return @failure.new( + { message: "Applying generated policy to '#{policy_load_path}' is not allowed", + request_error: e.response.body }, status: :forbidden + ) + when 404 + return @failure.new( + { message: "Unable to apply generated policy to '#{policy_load_path}'", + request_error: e.response.body }, status: :not_found + ) + else + return @failure.new( + { message: "Failed to apply generated policy to '#{policy_load_path}'", + request_error: e.to_s }, status: :bad_request + ) + end + rescue => e + return @failure.new( + { message: "Failed to apply generated policy to '#{policy_load_path}'", + request_error: e.to_s }, status: :bad_request + ) + end + + @success.new(response.body) + end + end + + def set_factory_variables(schema_variables:, factory_variables:, variable_path:, authorization:, account:) + # Only set secrets defined in the policy and present in factory payload + (schema_variables.keys & factory_variables.keys).each do |schema_variable| + + variable_id = @uri.encode_www_form_component("#{variable_path}/#{schema_variable}") + secret_path = "secrets/#{account}/variable/#{variable_id}" + + @http.post( + "http://localhost:#{ENV['PORT']}/#{secret_path}", + factory_variables[schema_variable].to_s, + { 'Authorization' => authorization } + ) + rescue RestClient::ExceptionWithResponse => e + case e.response.code + when 401 + return @failure.new("Role is unauthorized to set variable: '#{secret_path}'", status: :unauthorized) + when 403 + return @failure.new("Role lacks the privilege to set variable: '#{secret_path}'", status: :forbidden) + else + return @failure.new( + "Failed to set variable: '#{secret_path}'. Status Code: '#{e.response.code}', Response: '#{e.response.body}'", + status: :bad_request + ) + end + rescue => e + return @failure.new( + { message: "Failed set variable '#{secret_path}'", + request_error: e.to_s }, status: :bad_request + ) + end + @success.new('Variables successfully set') + end + end +end diff --git a/app/domain/factories/images/Basic-Sample.png b/app/domain/factories/images/Basic-Sample.png new file mode 100644 index 0000000000..1f51593332 Binary files /dev/null and b/app/domain/factories/images/Basic-Sample.png differ diff --git a/app/domain/factories/images/Readme-5.png b/app/domain/factories/images/Readme-5.png new file mode 100644 index 0000000000..21aedc9a66 Binary files /dev/null and b/app/domain/factories/images/Readme-5.png differ diff --git a/app/domain/factories/images/factory-create-request.png b/app/domain/factories/images/factory-create-request.png new file mode 100644 index 0000000000..d27a669fa7 Binary files /dev/null and b/app/domain/factories/images/factory-create-request.png differ diff --git a/app/domain/factories/images/factory-info-request.png b/app/domain/factories/images/factory-info-request.png new file mode 100644 index 0000000000..bceab0368c Binary files /dev/null and b/app/domain/factories/images/factory-info-request.png differ diff --git a/app/domain/factories/images/factory-list-request.png b/app/domain/factories/images/factory-list-request.png new file mode 100644 index 0000000000..9559793b65 Binary files /dev/null and b/app/domain/factories/images/factory-list-request.png differ diff --git a/app/domain/factories/images/factory-setup.png b/app/domain/factories/images/factory-setup.png new file mode 100644 index 0000000000..4d01a6b645 Binary files /dev/null and b/app/domain/factories/images/factory-setup.png differ diff --git a/app/domain/factories/images/factory-upgrade.png b/app/domain/factories/images/factory-upgrade.png new file mode 100644 index 0000000000..05e0f50bfb Binary files /dev/null and b/app/domain/factories/images/factory-upgrade.png differ diff --git a/app/domain/factories/renderer.rb b/app/domain/factories/renderer.rb new file mode 100644 index 0000000000..f5fd3cf9a5 --- /dev/null +++ b/app/domain/factories/renderer.rb @@ -0,0 +1,23 @@ +require 'responses' + +module Factories + class Renderer + def initialize(render_engine: ERB) + @render_engine = render_engine + @success = ::SuccessResponse + @failure = ::FailureResponse + end + + def render(template:, variables:) + @success.new(@render_engine.new(template, trim_mode: '-').result_with_hash(variables)) + + # If variable in template is missing from variable list + rescue NameError => e + @failure.new("Required template variable '#{e.name}' is missing") + rescue => e + # Need to add tests to understand what exceptions are thrown when + # variables are missing. This may not be enough. + @failure.new(e) + end + end +end diff --git a/app/domain/factories/templates/authenticators/v1/authn_oidc.rb b/app/domain/factories/templates/authenticators/v1/authn_oidc.rb new file mode 100644 index 0000000000..f71f3460e0 --- /dev/null +++ b/app/domain/factories/templates/authenticators/v1/authn_oidc.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require 'base64' + +module Factories + module Templates + module Authenticators + module V1 + class AuthnOidc + class << self + def policy_template + <<~TEMPLATE + - !policy + id: <%= id %> + annotations: + factory: authenticators/v1/authn-oidc + <% annotations.each do |key, value| -%> + <%= key %>: <%= value %> + <% end -%> + + body: + - !webservice + + - !variable provider-uri + - !variable client-id + - !variable client-secret + - !variable redirect-uri + - !variable claim-mapping + + - !group + id: authenticatable + annotations: + description: Group with permission to authenticate using this authenticator + + - !permit + role: !group authenticatable + privilege: [ read, authenticate ] + resource: !webservice + + - !webservice + id: status + annotations: + description: Web service for checking authenticator status + + - !group + id: operators + annotations: + description: Group with permission to check the authenticator status + + - !permit + role: !group operators + privilege: [ read ] + resource: !webservice status + TEMPLATE + end + + def data + Base64.encode64({ + version: 'v1', + policy: Base64.encode64(policy_template), + policy_branch: "conjur/authn-oidc", + schema: { + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "Authn-OIDC Template", + "description": "Create a new Authn-OIDC Authenticator", + "type": "object", + "properties": { + "id": { + "description": "Service ID of the Authenticator", + "type": "string" + }, + "annotations": { + "description": "Additional annotations", + "type": "object" + }, + "variables": { + "type": "object", + "properties": { + "provider-uri": { + "description": "OIDC Provider endpoint", + "type": "string" + }, + "client-id": { + "description": "OIDC Client ID", + "type": "string" + }, + "client-secret": { + "description": "OIDC Client Secret", + "type": "string" + }, + "redirect-uri": { + "description": "Target URL to redirect to after successful authentication", + "type": "string" + }, + "claim-mapping": { + "description": "OIDC JWT claim mapping. This value must match to a Conjur Host ID.", + "type": "string" + } + }, + "required": %w[provider-uri client-id client-secret claim-mapping] + } + }, + "required": %w[id variables] + } + }.to_json) + end + end + end + end + end + end +end diff --git a/app/domain/factories/templates/base/v1/base_policy.rb b/app/domain/factories/templates/base/v1/base_policy.rb new file mode 100644 index 0000000000..f114963d2f --- /dev/null +++ b/app/domain/factories/templates/base/v1/base_policy.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Factories + module Templates + module Base + module V1 + class BasePolicy + class << self + def policy + <<~TEMPLATE + - !policy + id: conjur + body: + - !policy + id: factories + body: + - !policy + id: core + annotations: + description: "Create Conjur primatives and manage permissions" + body: + - !variable v1/grant + - !variable v1/group + - !variable v1/host + - !variable v1/layer + - !variable v1/managed-policy + - !variable v1/policy + - !variable v1/user + + - !policy + id: authenticators + annotations: + description: "Generate new Authenticators" + body: + - !variable v1/authn-oidc + - !policy + id: connections + annotations: + description: "Create connections to external services" + body: + - !variable v1/database + - !variable v2/database + TEMPLATE + end + end + end + end + end + end +end diff --git a/app/domain/factories/templates/connections/v1/database.rb b/app/domain/factories/templates/connections/v1/database.rb new file mode 100644 index 0000000000..54a11196eb --- /dev/null +++ b/app/domain/factories/templates/connections/v1/database.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +require 'base64' + +module Factories + module Templates + module Connections + module V1 + class Database + class << self + def policy_template + <<~TEMPLATE + - !policy + id: <%= id %> + annotations: + factory: connections/v1/database + <% annotations.each do |key, value| -%> + <%= key %>: <%= value %> + <% end -%> + + body: + - &variables + - !variable url + - !variable port + - !variable username + - !variable password + - !variable ssl-certificate + - !variable ssl-key + - !variable ssl-ca-certificate + + - !group consumers + - !group administrators + + # consumers can read and execute + - !permit + resource: *variables + privileges: [ read, execute ] + role: !group consumers + + # administrators can update (and read and execute, via role grant) + - !permit + resource: *variables + privileges: [ update ] + role: !group administrators + + # administrators has role consumers + - !grant + member: !group administrators + role: !group consumers + TEMPLATE + end + + def data + Base64.encode64({ + version: 1, + policy: Base64.encode64(policy_template), + policy_branch: "<%= branch %>", + schema: { + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "Database Connection Template", + "description": "All information for connecting to a database", + "type": "object", + "properties": { + "id": { + "description": "Database Connection Identifier", + "type": "string" + }, + "branch": { + "description": "Policy branch to load this connection into", + "type": "string" + }, + "annotations": { + "description": "Additional annotations", + "type": "object" + }, + "variables": { + "type": "object", + "properties": { + "url": { + "description": "Database URL", + "type": "string" + }, + "port": { + "description": "Database Port", + "type": "string" + }, + "username": { + "description": "Database Username", + "type": "string" + }, + "password": { + "description": "Database Password", + "type": "string" + }, + "ssl-certificate": { + "description": "Client SSL Certificate", + "type": "string" + }, + "ssl-key": { + "description": "Client SSL Key", + "type": "string" + }, + "ssl-ca-certificate": { + "description": "CA Root Certificate", + "type": "string" + } + }, + "required": %w[url port username password] + } + }, + "required": %w[id branch variables] + } + }.to_json) + end + end + end + end + end + end +end diff --git a/app/domain/factories/templates/core/v1/grant.rb b/app/domain/factories/templates/core/v1/grant.rb new file mode 100644 index 0000000000..6fdc2613d5 --- /dev/null +++ b/app/domain/factories/templates/core/v1/grant.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'base64' + +module Factories + module Templates + module Core + module V1 + class Grant + class << self + def policy_template + <<~TEMPLATE + - !grant + member: !<%= member_resource_type %> <%= member_resource_id %> + role: !<%= role_resource_type %> <%= role_resource_id %> + TEMPLATE + end + + def data + Base64.encode64({ + version: 1, + policy: Base64.encode64(policy_template), + policy_branch: "<%= branch %>", + schema: { + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "Grant Template", + "description": "Assigns a Role to another Role", + "type": "object", + "properties": { + "branch": { + "description": "Policy branch to load this grant into", + "type": "string" + }, + "member_resource_type": { + "description": "The member type (group, host, user, etc.) for the grant", + "type": "string" + }, + "member_resource_id": { + "description": "The member resource identifier for the grant", + "type": "string" + }, + "role_resource_type": { + "description": "The role type (group, host, user, etc.) for the grant", + "type": "string" + }, + "role_resource_id": { + "description": "The role resource identifier for the grant", + "type": "string" + } + }, + "required": %w[branch member_resource_type member_resource_id role_resource_type role_resource_id] + } + }.to_json) + end + end + end + end + end + end +end diff --git a/app/domain/factories/templates/core/v1/group.rb b/app/domain/factories/templates/core/v1/group.rb new file mode 100644 index 0000000000..c299b9e356 --- /dev/null +++ b/app/domain/factories/templates/core/v1/group.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'base64' + +module Factories + module Templates + module Core + module V1 + class Group + class << self + def policy_template + <<~TEMPLATE + - !group + id: <%= id %> + <% if defined?(owner_role) && defined?(owner_type) -%> + owner: !<%= owner_type %> <%= owner_role %> + <% end -%> + annotations: + factory: core/v1/group + <% annotations.each do |key, value| -%> + <%= key %>: <%= value %> + <% end -%> + TEMPLATE + end + + def data + Base64.encode64({ + version: 1, + policy: Base64.encode64(policy_template), + policy_branch: "<%= branch %>", + schema: { + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "Group Template", + "description": "Creates a Conjur Group", + "type": "object", + "properties": { + "id": { + "description": "Group Identifier", + "type": "string" + }, + "branch": { + "description": "Policy branch to load this group into", + "type": "string" + }, + "owner_role": { + "description": "The Conjur Role that will own this group", + "type": "string" + }, + "owner_type": { + "description": "The resource type of the owner of this group", + "type": "string" + }, + "annotations": { + "description": "Additional annotations", + "type": "object" + } + }, + "required": %w[id branch] + } + }.to_json) + end + end + end + end + end + end +end diff --git a/app/domain/factories/templates/core/v1/managed_policy.rb b/app/domain/factories/templates/core/v1/managed_policy.rb new file mode 100644 index 0000000000..84095c7f35 --- /dev/null +++ b/app/domain/factories/templates/core/v1/managed_policy.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'base64' + +module Factories + module Templates + module Core + module V1 + class ManagedPolicy + class << self + def policy_template + <<~TEMPLATE + - !group <%= name %>-admins + - !policy + id: <%= name %> + owner: !group <%= name %>-admins + annotations: + factory: core/v1/managed-policy + <% annotations.each do |key, value| -%> + <%= key %>: <%= value %> + <% end -%> + TEMPLATE + end + + def data + Base64.encode64({ + version: 1, + policy: Base64.encode64(policy_template), + policy_branch: "<%= branch %>", + schema: { + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "Managed Policy Template", + "description": "Policy with an owner group", + "type": "object", + "properties": { + "name": { + "description": "Policy name (used to create the policy ID and the -admins owner group)", + "type": "string" + }, + "branch": { + "description": "Policy branch to load this policy into", + "type": "string" + }, + "annotations": { + "description": "Additional annotations", + "type": "object" + } + }, + "required": %w[name branch] + } + }.to_json) + end + end + end + end + end + end +end diff --git a/app/domain/factories/templates/core/v1/policy.rb b/app/domain/factories/templates/core/v1/policy.rb new file mode 100644 index 0000000000..a5d8aad9a3 --- /dev/null +++ b/app/domain/factories/templates/core/v1/policy.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require 'base64' + +module Factories + module Templates + module Core + module V1 + class Policy + class << self + def policy_template + <<~TEMPLATE + - !policy + id: <%= id %> + <% if defined?(owner_role) && defined?(owner_type) -%> + owner: !<%= owner_type %> <%= owner_role %> + <% end -%> + annotations: + factory: core/v1/policy + <% annotations.each do |key, value| -%> + <%= key %>: <%= value %> + <% end -%> + TEMPLATE + end + + def data + Base64.encode64({ + version: 1, + policy: Base64.encode64(policy_template), + policy_branch: "<%= branch %>", + schema: { + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "User Template", + "description": "Creates a Conjur Policy", + "type": "object", + "properties": { + "id": { + "description": "Policy ID", + "type": "string" + }, + "branch": { + "description": "Policy branch to load this policy into", + "type": "string" + }, + "owner_role": { + "description": "The Conjur Role that will own this policy", + "type": "string" + }, + "owner_type": { + "description": "The resource type of the owner of this policy", + "type": "string" + }, + "annotations": { + "description": "Additional annotations", + "type": "object" + } + }, + "required": %w[id branch] + } + }.to_json) + end + end + end + + end + end + end +end diff --git a/app/domain/factories/templates/core/v1/user.rb b/app/domain/factories/templates/core/v1/user.rb new file mode 100644 index 0000000000..c293a30d70 --- /dev/null +++ b/app/domain/factories/templates/core/v1/user.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require 'base64' + +module Factories + module Templates + module Core + module V1 + class User + class << self + def policy_template + <<~TEMPLATE + - !user + id: <%= id %> + <% if defined?(owner_role) && defined?(owner_type) -%> + owner: !<%= owner_type %> <%= owner_role %> + <% end -%> + <% if defined?(ip_range) -%> + restricted_to: <%= ip_range %> + <% end -%> + annotations: + factory: core/v1/user + <% annotations.each do |key, value| -%> + <%= key %>: <%= value %> + <% end -%> + TEMPLATE + end + + def data + Base64.encode64({ + version: 1, + policy: Base64.encode64(policy_template), + policy_branch: "<%= branch %>", + schema: { + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "User Template", + "description": "Creates a Conjur User", + "type": "object", + "properties": { + "id": { + "description": "User ID", + "type": "string" + }, + "branch": { + "description": "Policy branch to load this user into", + "type": "string" + }, + "owner_role": { + "description": "The Conjur Role that will own this user", + "type": "string" + }, + "owner_type": { + "description": "The resource type of the owner of this user", + "type": "string" + }, + "ip_range": { + "description": "Limits the network range the user is allowed to authenticate from", + "type": "string" + }, + "annotations": { + "description": "Additional annotations", + "type": "object" + } + }, + "required": %w[id branch] + } + }.to_json) + end + end + end + end + end + end +end diff --git a/app/domain/issuers/ephemeral_engines/conjur_dynamic_engine_client.rb b/app/domain/issuers/ephemeral_engines/conjur_dynamic_engine_client.rb new file mode 100644 index 0000000000..0ffb70d2a9 --- /dev/null +++ b/app/domain/issuers/ephemeral_engines/conjur_dynamic_engine_client.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require 'net/http' +require 'uri' +require 'json' + +require_relative('dynamic_engine_client') + +class ConjurDynamicEngineClient + include DynamicEngineClient + + @@secrets_service_address = ENV['EPHEMERAL_SECRETS_SERVICE_ADDRESS'] || "dynamic-secrets" + @@secrets_service_port = ENV['EPHEMERAL_SECRETS_SERVICE_PORT'] || "8080" + + def initialize(logger:, request_id:, http_client: nil) + if http_client + @client = http_client + else + @client = Net::HTTP.new(@@secrets_service_address, @@secrets_service_port.to_i) + @client.use_ssl = false # Service mesh takes care of the TLS communication + end + @logger = logger + @request_id = request_id + end + + def get_dynamic_secret(type, method, role_id, issuer_data, variable_data) + request_body = { + type: type, + method: method, + role: role_id, + issuer: hash_keys_to_snake_case(issuer_data), + secret: hash_keys_to_snake_case(variable_data) + } + + # Create the POST request + secret_request = Net::HTTP::Post.new("/secrets") + secret_request.body = request_body.to_json + + + # Add headers + secret_request.add_field('Content-Type', 'application/json') + secret_request.add_field('X-Request-ID', @request_id) + secret_request.add_field('X-Tenant-ID', tenant_id) + + # Filter out sensitive data from the request body and log the request + request_body[:issuer]["data"].delete("secret_access_key") + request_to_log = request_body.to_json + # Send the request and get the response + @logger.debug{LogMessages::Secrets::DynamicSecretRequestBody.new(@request_id, request_to_log)} + begin + response = @client.request(secret_request) + rescue => e + @logger.error(LogMessages::Secrets::DynamicSecretRemoteRequestFailure.new(@request_id, e.message)) + raise ApplicationController::InternalServerError, e.message + end + @logger.debug{LogMessages::Secrets::DynamicSecretRemoteResponse.new(@request_id, response.code)} + + case response.code.to_i + when 200..299 + return response.body + else + response_body = JSON.parse(response.body) + @logger.error(LogMessages::Secrets::DynamicSecretRemoteResponseFailure.new(@request_id, response_body['code'], response_body['message'], response_body['description'])) + raise ApplicationController::UnprocessableEntity, "Failed to create the dynamic secret. Code: #{response_body['code']}, Message: #{response_body['message']}, description: #{response_body['description']}" + end + end + + protected + + def hash_keys_to_snake_case(hash, level = 0) + result = {} + hash.each do |key, value| + transformed_key = key.to_s.gsub("-", "_").downcase + + # If the value is another hash, perform the same casting on that sub hash. + # We don't want unexpected behavior so currently this is limited to one level of + result[transformed_key] = if value.is_a?(Hash) && level.zero? + hash_keys_to_snake_case(value, 1) + else + value + end + end + result + end + + def tenant_id + Rails.application.config.conjur_config.tenant_id + end +end diff --git a/app/domain/issuers/ephemeral_engines/dynamic_engine_client.rb b/app/domain/issuers/ephemeral_engines/dynamic_engine_client.rb new file mode 100644 index 0000000000..e27a951c5e --- /dev/null +++ b/app/domain/issuers/ephemeral_engines/dynamic_engine_client.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module DynamicEngineClient + def get_dynamic_secret(type, method, role_id, issuer_data, variable_data) + raise NotImplementedError, "This method is not implemented because it's an interface" + end +end diff --git a/app/domain/issuers/issuer_types/aws_issuer_type.rb b/app/domain/issuers/issuer_types/aws_issuer_type.rb new file mode 100644 index 0000000000..49c58ac75b --- /dev/null +++ b/app/domain/issuers/issuer_types/aws_issuer_type.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +require_relative './issuer_base_type' + +class AwsIssuerType < IssuerBaseType + FEDERATION_TOKEN_METHOD = "federation-token" + ASSUME_ROLE_METHOD = "assume-role" + + REQUIRED_DATA_PARAM_MISSING = "'%s' is a required parameter in data and must be specified" + INVALID_INPUT_PARAM = "invalid parameter received in data. Only access_key_id and secret_access_key are allowed" + NUM_OF_EXPECTED_DATA_PARAMS = 2 + # the / slash here is not a regex delimiter, it is a literal character + SECRET_ACCESS_KEY_FIELD_VALID_FORMAT = /[A-Za-z0-9\/\+]{40}/.freeze + ACCESS_KEY_ID_FIELD_VALID_FORMAT = /(A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}/.freeze + INVALID_ACCESS_KEY_ID_FORMAT = "invalid 'access_key_id' parameter format. The access key ID must be a valid AWS access key ID. The valid foramt is: #{ACCESS_KEY_ID_FIELD_VALID_FORMAT}" + INVALID_SECRET_ACCESS_KEY_FORMAT = "invalid 'secret_access_key' parameter format. The secret access key must be a valid AWS secret access key. The valid format is: #{SECRET_ACCESS_KEY_FIELD_VALID_FORMAT}" + def validate(params) + super + validate_data(params[:data]) + end + + def validate_update(params) + super + return if params[:data].nil? + + validate_data(params[:data]) + end + + def validate_variable(secret_id, variable_method, variable_ttl, issuer_data) + super + + validate_variable_method(secret_id, variable_method) + validate_ttl(secret_id, variable_ttl, variable_method, issuer_data) + end + + private + + def validate_variable_method(secret_id, variable_method) + unless [AwsIssuerType::FEDERATION_TOKEN_METHOD, AwsIssuerType::ASSUME_ROLE_METHOD].include?(variable_method) + raise ArgumentError, "The 'method' annotation in the variable definition for dynamic secret \"#{secret_id}\" is not valid. Allowed values: assume-role, federation-token" + end + end +end + +private + +def validate_ttl(secret_id, ttl, method, issuer_data) + # ttl is not mandatory + if ttl.nil? + return + end + + if method == AwsIssuerType::FEDERATION_TOKEN_METHOD + if ttl < 900 || ttl > 43200 + message = "The TTL defined for dynamic secret '#{secret_id}' (method=federation token) is out of the allowed range: 900-43,200 seconds." + raise ArgumentError, message + end + elsif method == AwsIssuerType::ASSUME_ROLE_METHOD + if ttl < 900 || ttl > 129600 + message = "The TTL defined for dynamic secret '#{secret_id}' (method=assumed role) is out of the allowed range: 900-129,600 seconds." + raise ArgumentError, message + end + end + + validate_ttl_per_issuer(ttl, issuer_data) +end + +def validate_ttl_per_issuer(ttl, issuer_data) + return unless ttl > issuer_data[:max_ttl] + + message = "The TTL of the dynamic secret must be less than or equal to the maximum TTL defined in the issuer. (Max TTL: #{issuer_data[:max_ttl]})" + raise ArgumentError, message +end + +def validate_data(data) + unless data.is_a?(ActionController::Parameters) + raise ApplicationController::BadRequestWithBody, "'data' is not a valid JSON object; ensure that 'data' is properly formatted as a JSON object." + end + + data_fields = { + access_key_id: "access_key_id", + secret_access_key: "secret_access_key" + } + + data_fields.each do |field_symbol, field_string| + unless data.key?(field_symbol) + raise ApplicationController::UnprocessableEntity, format(IssuerBaseType::REQUIRED_PARAM_MISSING, field_string) + end + if data[field_symbol].nil? + raise ApplicationController::UnprocessableEntity, format(IssuerBaseType::REQUIRED_PARAM_MISSING, field_string) + end + + unless data[field_symbol].is_a?(String) + raise ApplicationController::UnprocessableEntity, format(IssuerBaseType::WRONG_PARAM_TYPE, field_string, "string") + end + + if data[field_symbol].empty? + raise ApplicationController::UnprocessableEntity, format(IssuerBaseType::REQUIRED_PARAM_MISSING, field_string) + end + end + + if data.keys.count > AwsIssuerType::NUM_OF_EXPECTED_DATA_PARAMS + raise ApplicationController::UnprocessableEntity, AwsIssuerType::INVALID_INPUT_PARAM + end + + validate_aws_acces_key_id(data[:access_key_id]) + validate_aws_secret_access_key(data[:secret_access_key]) +end + +def validate_aws_acces_key_id(access_key_string) + return if access_key_string.match?(AwsIssuerType::ACCESS_KEY_ID_FIELD_VALID_FORMAT) + + raise ApplicationController::UnprocessableEntity, AwsIssuerType::INVALID_ACCESS_KEY_ID_FORMAT +end + +def validate_aws_secret_access_key(secret_access_key_string) + return if secret_access_key_string.match?(AwsIssuerType::SECRET_ACCESS_KEY_FIELD_VALID_FORMAT) + + raise ApplicationController::UnprocessableEntity, AwsIssuerType::INVALID_SECRET_ACCESS_KEY_FORMAT +end diff --git a/app/domain/issuers/issuer_types/issuer_base_type.rb b/app/domain/issuers/issuer_types/issuer_base_type.rb new file mode 100644 index 0000000000..1a362e817b --- /dev/null +++ b/app/domain/issuers/issuer_types/issuer_base_type.rb @@ -0,0 +1,100 @@ +class IssuerBaseType + REQUIRED_PARAM_MISSING = "%s is a required parameter and must be specified".freeze + WRONG_PARAM_TYPE = "the '%s' parameter must be a %s".freeze + INVALID_INPUT_PARAM = "invalid parameter received in the request body. Only id, type, max_ttl and data are allowed".freeze + INVALID_INPUT_PARAM_UPDATE = "invalid parameter received in the request body. Only max_ttl and data are allowed".freeze + + ID_FIELD_ALLOWED_CHARACTERS = /\A[a-zA-Z0-9+\-_]+\z/ + ID_FIELD_MAX_ALLOWED_LENGTH = 60 + NUM_OF_EXPECTED_PARAMS = 4 + NUM_OF_EXPECTED_PARAMS_UPDATE = 2 + + def validate(params) + validate_issuer_id(params[:id]) + validate_max_ttl(params[:max_ttl]) + validate_type(params[:type]) + validate_not_nil_data(params[:data]) + validate_no_added_parameters(params) + end + + def validate_update(params) + validate_max_ttl(params[:max_ttl]) unless params[:max_ttl].nil? + validate_no_added_parameters_update(params) + end + + def validate_variable(secret_id, variable_method, variable_ttl, issuer_data) + # This method is empty because it is not needed in the base class + # and it will be implemented in the child classes + end + +end + +private + +def validate_issuer_id(id) + if id.nil? + raise ApplicationController::BadRequestWithBody, format(IssuerBaseType::REQUIRED_PARAM_MISSING, "id") + end + + unless id.is_a?(String) + raise ApplicationController::BadRequestWithBody, format(IssuerBaseType::WRONG_PARAM_TYPE, "id", "string") + end + + if id.empty? + raise ApplicationController::BadRequestWithBody, format(IssuerBaseType::REQUIRED_PARAM_MISSING, "id") + end + + unless id.match?(IssuerBaseType::ID_FIELD_ALLOWED_CHARACTERS) + raise ApplicationController::BadRequestWithBody, "invalid 'id' parameter. Only the following characters are supported: A-Z, a-z, 0-9, +, -, and _" + end + + if id.length > IssuerBaseType::ID_FIELD_MAX_ALLOWED_LENGTH + raise ApplicationController::BadRequestWithBody, "'id' parameter length exceeded. Limit the length to #{IssuerBaseType::ID_FIELD_MAX_ALLOWED_LENGTH} characters" + end +end + +def validate_max_ttl(max_ttl) + if max_ttl.nil? + raise ApplicationController::BadRequestWithBody, format(IssuerBaseType::REQUIRED_PARAM_MISSING, "max_ttl") + end + + unless max_ttl.is_a?(Integer) && max_ttl.positive? + raise ApplicationController::BadRequestWithBody, format(IssuerBaseType::WRONG_PARAM_TYPE, "max_ttl", "positive integer") + end + + unless max_ttl.between?(900, 43_200) + raise ApplicationController::BadRequestWithBody, format(IssuerBaseType::WRONG_PARAM_TYPE, "max_ttl", "between 900 and 43200") + end +end + +def validate_type(type) + if type.nil? + raise ApplicationController::BadRequestWithBody, format(IssuerBaseType::REQUIRED_PARAM_MISSING, "type") + end + + unless type.is_a?(String) + raise ApplicationController::BadRequestWithBody, format(IssuerBaseType::WRONG_PARAM_TYPE, "type", "string") + end + + if type.empty? + raise ApplicationController::BadRequestWithBody, format(IssuerBaseType::REQUIRED_PARAM_MISSING, "type") + end +end + +def validate_not_nil_data(data) + if data.nil? + raise ApplicationController::BadRequestWithBody, format(IssuerBaseType::REQUIRED_PARAM_MISSING, "data") + end +end + +def validate_no_added_parameters_update(params) + if params.keys.count > IssuerBaseType::NUM_OF_EXPECTED_PARAMS_UPDATE + raise ApplicationController::BadRequestWithBody, IssuerBaseType::INVALID_INPUT_PARAM_UPDATE + end +end + +def validate_no_added_parameters(params) + if params.keys.count != IssuerBaseType::NUM_OF_EXPECTED_PARAMS + raise ApplicationController::BadRequestWithBody, IssuerBaseType::INVALID_INPUT_PARAM + end +end diff --git a/app/domain/issuers/issuer_types/issuer_type_factory.rb b/app/domain/issuers/issuer_types/issuer_type_factory.rb new file mode 100644 index 0000000000..25216953a6 --- /dev/null +++ b/app/domain/issuers/issuer_types/issuer_type_factory.rb @@ -0,0 +1,11 @@ +require_relative './aws_issuer_type' + +class IssuerTypeFactory + def create_issuer_type(type) + if !type.nil? && type.casecmp("aws").zero? + AwsIssuerType.new + else + raise ApplicationController::BadRequestWithBody, "issuer type is unsupported" + end + end +end diff --git a/app/domain/logs.rb b/app/domain/logs.rb index a49d897f2a..4ff2e63ca3 100644 --- a/app/domain/logs.rb +++ b/app/domain/logs.rb @@ -24,6 +24,78 @@ module Conjur code: "CONJ00038I" ) + SlosiloRotate = ::Util::TrackableLogMessageClass.new( + msg: "Slosilo key is rotated successfully", + code: "CONJ00156I" + ) + + AlreadyExists = ::Util::TrackableLogMessageClass.new( + msg: "The instance {0} already exists , {1}", + code: "CONJ00164E" + ) + + GeneralError = ::Util::TrackableErrorClass.new( + msg: "Unexpected error occurred: {0}", + code: "CONJ00163E" + ) + + end + + module Endpoints + EndpointRequested = ::Util::TrackableLogMessageClass.new( + msg: "{0} endpoint is called", + code: "CONJ00152D" + ) + + EndpointFinishedSuccessfully = ::Util::TrackableLogMessageClass.new( + msg: "{0} endpoint is finished successfully", + code: "CONJ00153D" + ) + + EndpointFinishedSuccessfullyWithLimitAndOffset = ::Util::TrackableLogMessageClass.new( + msg: "{0} endpoint is finished successfully with {1}-limit {2}-offset", + code: "CONJ00155I" + ) + + EndpointFinishedSuccessfullyWithCount = ::Util::TrackableLogMessageClass.new( + msg: "{0} endpoint is finished successfully with {1}-count", + code: "CONJ00157I" + ) + + end + + module Edge + EdgeTelemetry = ::Util::TrackableLogMessageClass.new( + msg: "Requested from tenant: {0}." \ + "Edge {1} was last synced at {2} {10}. Requests served during last sync interval: " \ + "get_secret={3}, auth_apikey={4}, auth_jwt={5}, redirect={6}." \ + "Edge_Info: version={7}, platform={8}, install_time={9}.", + code: "CONJ00158" + ) + end + + module Dynamic + DynamicVariableTelemetry = ::Util::TrackableLogMessageClass.new( + msg: "{0} dynamic variable '{1}' for {2} at #{Time.now}", + code: "CONJ00168I" + ) + end + + module Redis + RedisAccessStart = ::Util::TrackableLogMessageClass.new( + msg: "Starting {0} in Redis", + code: "CONJ00501D" + ) + + RedisAccessEnd = ::Util::TrackableLogMessageClass.new( + msg: "Finished {0} in Redis. Response: {1}", + code: "CONJ00502D" + ) + + RedisAccessFailure = ::Util::TrackableLogMessageClass.new( + msg: "Error while {0} in Redis. Message: {1}", + code: "CONJ00503E" + ) end module Authentication @@ -116,7 +188,7 @@ module ResourceRestrictions RetrievedAnnotationValue = ::Util::TrackableLogMessageClass.new( msg: "Retrieved value of annotation '{0-annotation-name}'", - code: "CONJ00024I" + code: "CONJ00024D" ) ValidatingResourceRestrictions = ::Util::TrackableLogMessageClass.new( @@ -131,7 +203,7 @@ module ResourceRestrictions ExtractingRestrictionsFromResource = ::Util::TrackableLogMessageClass.new( msg: "Extracting resource restrictions for authenticator '{0-authn-name}' from host '{1-host-name}'", - code: "CONJ00032I" + code: "CONJ00032D" ) ExtractedResourceRestrictions = ::Util::TrackableLogMessageClass.new( @@ -282,6 +354,11 @@ module AuthnIam code: "CONJ00036D" ) + RetryWithGlobalEndpoint = ::Util::TrackableLogMessageClass.new( + msg: "Retrying IAM request signed in 'us-east-1' region with global STS endpoint.", + code: "CONJ00043D" + ) + end module AuthnAzure @@ -307,12 +384,12 @@ module AuthnJwt IssuerResourceNameConfiguration = ::Util::TrackableLogMessageClass.new( msg: "\"issuer\" value will be taken from '{0-resource-id}'", - code: "CONJ00054I" + code: "CONJ00054D" ) RetrievedIssuerValue = ::Util::TrackableLogMessageClass.new( msg: "Retrieved \"issuer\" with value '{0}'", - code: "CONJ00055I" + code: "CONJ00055D" ) ParsingIssuerFromUri = ::Util::TrackableLogMessageClass.new( @@ -332,12 +409,12 @@ module AuthnJwt SelectedIdentityProviderInterface = ::Util::TrackableLogMessageClass.new( msg: "Selected identity provider interface: '{0-identity-provider-interface-name}'", - code: "CONJ00059I" + code: "CONJ00059D" ) RetrievedResourceValue = ::Util::TrackableLogMessageClass.new( msg: "Retrieved value '{0-resource-value}' of resource name '{1-resource-name}'", - code: "CONJ00060I" + code: "CONJ00060D" ) CheckingIdentityFieldExists = ::Util::TrackableLogMessageClass.new( @@ -377,7 +454,7 @@ module AuthnJwt FetchedJwtClaimsToValidate = ::Util::TrackableLogMessageClass.new( msg: "Fetched JWT claims '{0-claims-list}' to validate", - code: "CONJ00068I" + code: "CONJ00068D" ) AddingJwtClaimToValidate = ::Util::TrackableLogMessageClass.new( @@ -397,7 +474,7 @@ module AuthnJwt FetchingJwksFromProvider = ::Util::TrackableLogMessageClass.new( msg: "Fetching JWKS from '{0-uri}'...", - code: "CONJ00072I" + code: "CONJ00072D" ) FetchJwtUriKeysSuccess = ::Util::TrackableLogMessageClass.new( @@ -417,7 +494,7 @@ module AuthnJwt SelectedSigningKeyInterface = ::Util::TrackableLogMessageClass.new( msg: "Selected signing key interface: '{0-signing-key-interface-name}'", - code: "CONJ00076I" + code: "CONJ00076D" ) ConvertingJwtClaimToVerificationOption = ::Util::TrackableLogMessageClass.new( @@ -527,7 +604,7 @@ module AuthnJwt FoundJwtIdentity= ::Util::TrackableLogMessageClass.new( msg: "Successfully found JWT identity '{0}'", - code: "CONJ00098I" + code: "CONJ00098D" ) ValidatedJwtStatusConfiguration = ::Util::TrackableLogMessageClass.new( @@ -567,12 +644,12 @@ module AuthnJwt CreateJwtRestrictionsValidatorInstance = ::Util::TrackableLogMessageClass.new( msg: "Creating JWT restrictions validator (validate_restrictions) instance...", - code: "CONJ00108I" + code: "CONJ00108D" ) CreatedJwtRestrictionsValidatorInstance = ::Util::TrackableLogMessageClass.new( msg: "Successfully created JWT restrictions validator (validate_restrictions) instance", - code: "CONJ00109I" + code: "CONJ00109D" ) FetchingIdentityPath = ::Util::TrackableLogMessageClass.new( @@ -582,7 +659,7 @@ module AuthnJwt FetchedIdentityPath = ::Util::TrackableLogMessageClass.new( msg: "Successfully fetched JWT identity path '{0-identity-path}'", - code: "CONJ00111I" + code: "CONJ00111D" ) FetchingIdentityByInterface = ::Util::TrackableLogMessageClass.new( @@ -592,7 +669,7 @@ module AuthnJwt FetchedIdentityByInterface = ::Util::TrackableLogMessageClass.new( msg: "Successfully fetched identity '{0-identity}' by interface: '{1-interface-name}'", - code: "CONJ00113I" + code: "CONJ00113D" ) AddingIdentityPrefixToIdentity = ::Util::TrackableLogMessageClass.new( @@ -647,7 +724,7 @@ module AuthnJwt FetchedEnforcedClaims = ::Util::TrackableLogMessageClass.new( msg: "Successfully fetched enforced claims '{0-enforced-claims}'", - code: "CONJ00124I" + code: "CONJ00124D" ) ParsingClaimAliases = ::Util::TrackableLogMessageClass.new( @@ -676,17 +753,17 @@ module AuthnJwt FetchedClaimAliases = ::Util::TrackableLogMessageClass.new( msg: "Successfully fetched claim aliases '{0-claim-aliases}'", - code: "CONJ00130I" + code: "CONJ00130D" ) CreateContraintsFromPolicy = ::Util::TrackableLogMessageClass.new( msg: "Creating constraints from policy...", - code: "CONJ00131I" + code: "CONJ00131D" ) CreatedConstraintsFromPolicy = ::Util::TrackableLogMessageClass.new( msg: "Successfully created constraints from policy", - code: "CONJ00132I" + code: "CONJ00132D" ) ConvertingClaimAccordingToAlias = ::Util::TrackableLogMessageClass.new( @@ -721,7 +798,7 @@ module AuthnJwt FetchedAudienceValue = ::Util::TrackableLogMessageClass.new( msg: "Successfully fetched audience value '{0-value}'", - code: "CONJ00139I" + code: "CONJ00139D" ) ValidatedAudienceConfiguration = ::Util::TrackableLogMessageClass.new( @@ -741,7 +818,7 @@ module AuthnJwt ParsingStaticSigningKeys = ::Util::TrackableLogMessageClass.new( msg: "Parsing JWKS from public-keys value...", - code: "CONJ00143I" + code: "CONJ00143D" ) ParsedStaticSigningKeys = ::Util::TrackableLogMessageClass.new( @@ -750,11 +827,63 @@ module AuthnJwt ) end end + + module Issuers + + IssuerPolicyNotFound = ::Util::TrackableErrorClass.new( + msg: "The policy of issuer {0} was not found", + code: "CONJ00158W" + ) + + IssuerEndpointForbidden = ::Util::TrackableErrorClass.new( + msg: "Action {0} is not allowed on the issuers endpoint", + code: "CONJ00159W" + ) + + TelemetryIssuerLog = ::Util::TrackableLogMessageClass.new( + msg: "{0} issuer '{1}/issuers/{2}' for {3} at #{Time.now}", + code: "CONJ00167I" + ) + + end + + module Secrets + + DynamicSecretRequest = ::Util::TrackableLogMessageClass.new( + msg: "Received a dynamic secret request. Request ID [{3}], Issuer ID [{0}], issuer type [{1}], dynamic method [{2}]", + code: "CONJ00160I" + ) + + DynamicSecretRemoteRequest = ::Util::TrackableLogMessageClass.new( + msg: "Calling the dynamic secrets service. Request ID [{0}]", + code: "CONJ00161I" + ) + + DynamicSecretRemoteResponse = ::Util::TrackableLogMessageClass.new( + msg: "Received the response from the dynamic secrets service. Request ID [{0}], HTTP code [{1}]", + code: "CONJ00161D" + ) + + DynamicSecretRemoteRequestFailure = ::Util::TrackableLogMessageClass.new( + msg: "Failed to send the request to the dynamic secrets service. Request ID [{0}], error: {1}", + code: "CONJ00162E" + ) + + DynamicSecretRemoteResponseFailure = ::Util::TrackableLogMessageClass.new( + msg: "Failed to create the dynamic secret. Request ID [{0}], code: {1}, message: {2}, description: {3}", + code: "CONJ00163E" + ) + + DynamicSecretRequestBody = ::Util::TrackableLogMessageClass.new( + msg: "Dynamic secret request ID [{0}], body: {1}", + code: "CONJ00166D" + ) + + end # This are log messages so its okay there are many # :reek:TooManyConstants module Util - RateLimitedCacheUpdated = ::Util::TrackableLogMessageClass.new( msg: "Rate limited cache updated successfully", code: "CONJ00016D" @@ -796,6 +925,17 @@ module Util code: "CONJ00026D" ) + FailedSerializationOfResources = ::Util::TrackableLogMessageClass.new( + msg: "Failed serialization of resources in {0} endpoint with {1}-limit {2}-offset " \ + "and {3}-size of failed resources, id of failed resource {4} for example", + code: "CONJ00154I" + ) + + LogBeforeFluentd = ::Util::TrackableLogMessageClass.new( + msg: "Event sent to audit is: {0}", + code: "CONJ00500I" + ) + end module Config @@ -824,4 +964,11 @@ module Config code: "CONJ00150W" ) end + + module Monitoring + ExceptionDuringRequestRecording = ::Util::TrackableLogMessageClass.new( + msg: "Exception during request recording: {0-exception}", + code: "CONJ00151D" + ) + end end diff --git a/app/domain/messages/message_job.rb b/app/domain/messages/message_job.rb new file mode 100644 index 0000000000..11a8c81179 --- /dev/null +++ b/app/domain/messages/message_job.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true +require 'singleton' +require_relative '../aws/sns_client' +class MessageJob + include Singleton + + @logger + + def initialize + @logger = Rails.logger + @message_version = '1.0' + end + + def run + return unless Rails.application.config.conjur_config.try(:conjur_pubsub_enabled) + unique_transaction_ids_count_value = Event.unique_transaction_ids_count + return if unique_transaction_ids_count_value.zero? + Sequel::Model.db.transaction do + Sequel::Model.db.run("SET idle_in_transaction_session_timeout = '#{timeout_value(unique_transaction_ids_count_value)}s';") + lock_acquired = acquire_lock + handle_process_message(lock_acquired) + end + end + + private + + def handle_process_message(lock_acquired) + if lock_acquired + @logger.debug{"Acquired lock with id #{get_lock_identifier}, starting processing message."} + process_message + else + @logger.debug{"Could not acquire lock with id #{get_lock_identifier}"} + end + end + + # lock releases in the transaction end + def acquire_lock + Sequel::Model.db.fetch("SELECT pg_try_advisory_xact_lock(:lock_id) AS lock_acquired;", lock_id: get_lock_identifier).first[:lock_acquired] + end + + def process_message + grouped_events = Event.get_all_events_grouped + grouped_events.each do |transaction_id, events| + filled_events_with_value = fill_events_hash(events) + events_chunks = split_events_into_json_chunks_recursive(filled_events_with_value) + send_message(events_chunks, transaction_id) + delete_by_events_id(map_to_ids(events)) + end + end + + def send_message(events_chunks, transaction_id) + number_of_messages = events_chunks.size + events_chunks.each_with_index do |message, index| + message_attributes = { + "source" => { + data_type: "String", + string_value: "Conjur" # This is default value for conjur service + }, + "id" => { + data_type: "String", + string_value: transaction_id.to_s + }, + "version" => { + data_type: "String", + string_value: @message_version + }, + "Parts" => { + data_type: "Number", + string_value: number_of_messages.to_s + }, + "PartNumber" => { + data_type: "Number", + string_value: (index + 1).to_s + }, + "tenant_id" => { + data_type: "String", + string_value: ENV['TENANT_ID'] + }, + "time" => { + data_type: "String", + string_value: Time.now.utc.iso8601(3).to_s + } + } + SnsClient.instance.publish(message, message_attributes) + end + end + + # return timeout value in seconds + def timeout_value(id_count) + id_count * 30 + end + + def fill_events_hash(events) + filled_events = [] + events.each do |event| + value_hash = JSON.parse(event[:event_value]) + value_hash['id'] = event[:event_id].to_s + value_hash['time'] = event[:created_at].iso8601(3) + value_hash['type'] = event[:event_type] + filled_events.append(value_hash) + end + filled_events + end + + # I take in to account that a single event never exceeds the max size + def split_events_into_json_chunks_recursive(events, max_size_kb = 120) + json_str = events_json(events) + if json_str.bytesize <= max_size_kb * 1024 + return [json_str] + else + if events.size == 1 + original_event = events.first + error_event = { + 'id' => original_event['id'], + 'time' => original_event['time'], + 'type' => original_event['type'], + 'message_error' => 'Event size exceeds the maximum allowed size' + } + @logger.error("Event with id #{original_event['id']} exceeds the maximum allowed size of #{max_size_kb}KB") + return [events_json([error_event])] + end + mid_index = events.size / 2 + left_chunks = split_events_into_json_chunks_recursive(events[0...mid_index], max_size_kb) + right_chunks = split_events_into_json_chunks_recursive(events[mid_index..-1], max_size_kb) + return left_chunks + right_chunks + end + end + + def events_json(filled_events) + { "events": filled_events }.to_json + end + + def map_to_ids(events) + events.map { |event| event[:event_id] } + end + + def delete_by_events_id(events_ids) + Event.delete_by_id(events_ids) + end + + def get_lock_identifier + ENV['TENANT_ID'].to_i(36) + end +end diff --git a/app/domain/permissions/permissions_handler.rb b/app/domain/permissions/permissions_handler.rb new file mode 100644 index 0000000000..7321f3df47 --- /dev/null +++ b/app/domain/permissions/permissions_handler.rb @@ -0,0 +1,90 @@ +module PermissionsHandler + include ParamsValidator + include ResourcesHandler + + def add_permissions(resources_privileges, secret_id, policy_id) + resources_privileges.each do |resource_id, privileges| + privileges.each do |p| + ::Permission.create( + resource_id: secret_id, + privilege: p, + role_id: resource_id, + policy_id: policy_id + ) + end + end + end + + def delete_resource_permissions(resource) + Permission.where(resource_id: resource.id).delete + end + + def get_permissions(resource) + permissions = Permission.where(resource_id: resource.id).group(:role_id) + .select(:role_id, Sequel.function(:array_agg, :privilege).as(:privileges)) + permissions = permissions.map do |permission| + role_parts = permission[:role_id].split(':') + { + subject: { + id: role_parts[2], + kind: role_parts[1] + }, + privileges: permission[:privileges].to_a + } + end + # Return empty list if no permissions defined + unless permissions + permissions = [] + end + permissions + end + + # Validates the permissions section of the request is valid and returns a map between the resource id and its privileges + def validate_permissions(permissions, allowed_privilege) + resources_privileges = {} + permissions.each do |permission| + # Validate subject field exists + subject = permission[:subject] + raise Errors::Conjur::ParameterMissing, "Privilege Subject" unless subject + + data_fields = { + kind: { + field_info: { + type: String, + value: subject[:kind] + }, + validators: [ + method(:validate_field_required), + method(:validate_field_type), + lambda { |field_name, field_info| validate_resource_kind(field_info[:value], subject[:id], %w[user host group]) }] + }, + id: { + field_info: { + type: String, + value: subject[:id] + }, + validators: [method(:validate_field_required), method(:validate_field_type)] + }, + privileges: { + field_info: { + type: String, + value: permission[:privileges] + }, + validators: [ + method(:validate_field_required), + lambda { |field_name, field_info| validate_privilege(subject[:id], field_info[:value], allowed_privilege) } + ] + } + } + validate_data_fields(data_fields) + + # Validate subject resource exists + resource_id = full_resource_id(subject[:kind], subject[:id]) + raise Exceptions::RecordNotFound, resource_id unless Resource[resource_id] + # Update resource privileges + resources_privileges[resource_id] = permission[:privileges] + end + + resources_privileges + end +end \ No newline at end of file diff --git a/app/domain/policy-templates/base_template.rb b/app/domain/policy-templates/base_template.rb new file mode 100644 index 0000000000..aae0c21754 --- /dev/null +++ b/app/domain/policy-templates/base_template.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module PolicyTemplates + class BaseTemplate + def template + raise NotImplementedError, "This method is not implemented because it's a base class" + end + end +end \ No newline at end of file diff --git a/app/domain/policy-templates/edge/create_edge.rb b/app/domain/policy-templates/edge/create_edge.rb new file mode 100644 index 0000000000..e2ceb11f75 --- /dev/null +++ b/app/domain/policy-templates/edge/create_edge.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true +require_relative '../base_template' + +module PolicyTemplates + class CreateEdge < PolicyTemplates::BaseTemplate + def template + <<~TEMPLATE + - !policy + id: edge-<%= edge_identifier %> + body: + - !host + id: edge-host-<%= edge_identifier %> + annotations: + authn/api-key: true + - !policy + id: edge-installer-<%= edge_identifier %> + body: + - !host + id: edge-installer-host-<%= edge_identifier %> + annotations: + authn/api-key: true + + - !grant + role: !group edge-hosts + members: + - !host edge-<%= edge_identifier %>/edge-host-<%= edge_identifier %> + + - !grant + role: !group edge-installer-group + members: + - !host edge-installer-<%= edge_identifier %>/edge-installer-host-<%= edge_identifier %> + + - !permit + role: !host edge-installer-<%= edge_identifier %>/edge-installer-host-<%= edge_identifier %> + privileges: [ update ] + resources: !host edge-<%= edge_identifier %>/edge-host-<%= edge_identifier %> + TEMPLATE + end + end +end diff --git a/app/domain/policy-templates/edge/delete_edge.rb b/app/domain/policy-templates/edge/delete_edge.rb new file mode 100644 index 0000000000..bf48a5b4d3 --- /dev/null +++ b/app/domain/policy-templates/edge/delete_edge.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true +require_relative '../base_template' + +module PolicyTemplates + class DeleteEdge < PolicyTemplates::BaseTemplate + def template + <<~TEMPLATE + - !delete + record: !host edge-<%= edge_identifier %>/edge-host-<%= edge_identifier %> + + - !delete + record: !policy edge-<%= edge_identifier %> + + - !delete + record: !host edge-installer-<%= edge_identifier %>/edge-installer-host-<%= edge_identifier %> + + - !delete + record: !policy edge-installer-<%= edge_identifier %> + + TEMPLATE + end + end +end diff --git a/app/domain/policy-templates/grants/grant_host_safe.rb b/app/domain/policy-templates/grants/grant_host_safe.rb new file mode 100644 index 0000000000..d66344888c --- /dev/null +++ b/app/domain/policy-templates/grants/grant_host_safe.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true +require_relative '../base_template' + +module PolicyTemplates + class GrantHostSafe < PolicyTemplates::BaseTemplate + def template + <<~TEMPLATE + - !grant + role: !group delegation/consumers + member: !host <%= id %> + TEMPLATE + end + end +end \ No newline at end of file diff --git a/app/domain/policy-templates/groups/add_member.rb b/app/domain/policy-templates/groups/add_member.rb new file mode 100644 index 0000000000..dae4e5c8c0 --- /dev/null +++ b/app/domain/policy-templates/groups/add_member.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true +require_relative '../base_template' + +module PolicyTemplates + class AddMemberToGroup < PolicyTemplates::BaseTemplate + def template + <<~TEMPLATE + - !grant + role: !group <%= id %> + member: + - !<%= kind %> <%= member_id %> + TEMPLATE + end + end +end \ No newline at end of file diff --git a/app/domain/policy-templates/groups/remove_member.rb b/app/domain/policy-templates/groups/remove_member.rb new file mode 100644 index 0000000000..f5ee09d06c --- /dev/null +++ b/app/domain/policy-templates/groups/remove_member.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true +require_relative '../base_template' + +module PolicyTemplates + class RemoveMemberFromGroup < PolicyTemplates::BaseTemplate + def template + <<~TEMPLATE + - !revoke + role: !group <%= id %> + member: + - !<%= kind %> <%= member_id %> + TEMPLATE + end + end +end \ No newline at end of file diff --git a/app/domain/policy-templates/hosts/create_host.rb b/app/domain/policy-templates/hosts/create_host.rb new file mode 100644 index 0000000000..6a20c38dbe --- /dev/null +++ b/app/domain/policy-templates/hosts/create_host.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true +require_relative '../base_template' + +module PolicyTemplates + class CreateHost < PolicyTemplates::BaseTemplate + def template + <<~TEMPLATE + - !host + id: "<%= id %>" + <% unless annotations.nil? || annotations.empty? %> + annotations: + <%- annotations.each do |key, value| -%> + <%= key %>: <%= value %> + <%- end -%> + <% end %> + TEMPLATE + end + end +end \ No newline at end of file diff --git a/app/domain/policy-templates/issuers/create_issuer.rb b/app/domain/policy-templates/issuers/create_issuer.rb new file mode 100644 index 0000000000..da24db0c23 --- /dev/null +++ b/app/domain/policy-templates/issuers/create_issuer.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true +require_relative '../base_template' + +module PolicyTemplates + class CreateIssuer < PolicyTemplates::BaseTemplate + def template + <<~TEMPLATE + - !policy + id: "<%= id %>" + body: + - !permit + role: !group delegation/consumers + privileges: [ use ] + resource: !policy + + - !policy + id: delegation + body: + - !group consumers + TEMPLATE + end + end +end \ No newline at end of file diff --git a/app/domain/policy-templates/issuers/delete_issuer.rb b/app/domain/policy-templates/issuers/delete_issuer.rb new file mode 100644 index 0000000000..3212a88320 --- /dev/null +++ b/app/domain/policy-templates/issuers/delete_issuer.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true +require_relative '../base_template' + +module PolicyTemplates + class DeleteIssuer < PolicyTemplates::BaseTemplate + def template + <<~TEMPLATE + - !delete + record: !group <%= id %>/delegation/consumers + + - !delete + record: !policy <%= id %>/delegation + + - !delete + record: !policy <%= id %> + TEMPLATE + end + end +end \ No newline at end of file diff --git a/app/domain/policy-templates/synchronizer/create_synchronizer.rb b/app/domain/policy-templates/synchronizer/create_synchronizer.rb new file mode 100644 index 0000000000..d7b41efd50 --- /dev/null +++ b/app/domain/policy-templates/synchronizer/create_synchronizer.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true +require_relative '../base_template' + +module PolicyTemplates + class CreateSynchronizer < PolicyTemplates::BaseTemplate + def template + <<~TEMPLATE + - !policy + id: synchronizer-<%= synchronizer_identifier %> + body: + - !host + id: synchronizer-host-<%= synchronizer_identifier %> + annotations: + authn/api-key: true + - !policy + id: synchronizer-installer-<%= synchronizer_identifier %> + body: + - !host + id: synchronizer-installer-host-<%= synchronizer_identifier %> + annotations: + authn/api-key: true + + - !grant + role: !group synchronizer-hosts + members: + - !host synchronizer-<%= synchronizer_identifier %>/synchronizer-host-<%= synchronizer_identifier %> + + - !grant + role: !group synchronizer-installer-hosts + members: + - !host synchronizer-installer-<%= synchronizer_identifier %>/synchronizer-installer-host-<%= synchronizer_identifier %> + + - !permit + role: !host synchronizer-installer-<%= synchronizer_identifier %>/synchronizer-installer-host-<%= synchronizer_identifier %> + privileges: [ update ] + resources: !host synchronizer-<%= synchronizer_identifier %>/synchronizer-host-<%= synchronizer_identifier %> + TEMPLATE + end + end +end \ No newline at end of file diff --git a/app/domain/rbac/permission.rb b/app/domain/rbac/permission.rb new file mode 100644 index 0000000000..7296225ff2 --- /dev/null +++ b/app/domain/rbac/permission.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module RBAC + class Permission + def initialize(resource_library: ::Resource, role_library: ::Role) + @resource_library = resource_library + @role_library = role_library + + @success = ::SuccessResponse + @failure = ::FailureResponse + end + + # Accepts both `role` and `role_id` to provide flexibility of lookup. + # Note that `role` is taken ahead of `role_id`. + def permitted?(resource_id:, privilege:, role: nil, role_id: nil) + resource = @resource_library[resource_id] + + unless resource.present? + return @failure.new( + "Resource '#{resource_id}' was not found", + status: :unauthorized, + exception: Errors::Conjur::RequiredResourceMissing.new(resource_id) + ) + end + + # Lookup role if the role ID was provided (and the role was not) + role ||= @role_library[role_id] + + if role.present? && role.allowed_to?(privilege, resource) + @success.new(role) + else + @failure.new( + role || role_id, + status: :forbidden, + exception: Errors::Authentication::Security::RoleNotAuthorizedOnResource.new( + [role.try(:kind), role.try(:identifier)].join('/'), + privilege, + resource_id + ) + ) + end + end + end +end diff --git a/app/domain/repos/conjur_ca.rb b/app/domain/repos/conjur_ca.rb index bb45132285..e0f00f777b 100644 --- a/app/domain/repos/conjur_ca.rb +++ b/app/domain/repos/conjur_ca.rb @@ -5,8 +5,8 @@ class ConjurCA def self.create(resource_id) ca_info = ::Conjur::CaInfo.new(resource_id) ca = ::Util::OpenSsl::CA.from_subject(ca_info.cert_subject) - Secret.create(resource_id: ca_info.cert_id, value: ca.cert.to_pem) - Secret.create(resource_id: ca_info.key_id, value: ca.key.to_pem) + ::DB::Service::SecretService.instance.secret_value_change(ca_info.cert_id, ca.cert.to_pem) + ::DB::Service::SecretService.instance.secret_value_change(ca_info.key_id, ca.key.to_pem) ca.cert end diff --git a/app/domain/resources/resources_handler.rb b/app/domain/resources/resources_handler.rb new file mode 100644 index 0000000000..cfbb3e82f7 --- /dev/null +++ b/app/domain/resources/resources_handler.rb @@ -0,0 +1,37 @@ +require './app/domain/util/static_account' + +module ResourcesHandler + def account + @account ||= StaticAccount.account + end + + def full_resource_id(type, full_id) + #We support the path to start with / and without but for full id we need it without / + if full_id.start_with?("/") + full_id = full_id[1..-1] + end + [ account, type, full_id ].join(":") + end + + def parse_resource_id(resource_id, v2_syntax: false) + if resource_id.nil? || resource_id&.count(':') != 2 + raise Exceptions::InvalidResourceId, resource_id + end + + account, type, full_id = resource_id.split(':') + branch, _, name = full_id.rpartition('/') + if branch.empty? + branch = 'root' + end + type = Util::V2Helpers.translate_kind(type) if v2_syntax + { account: account, type: type, branch: branch, name: name, id: full_id } + end + + def get_resource(type, resource_id) + full_resource_id = full_resource_id(type, resource_id) + resource = Resource[full_resource_id] + raise Exceptions::RecordNotFound, full_resource_id unless resource + + resource + end +end diff --git a/app/domain/responses.rb b/app/domain/responses.rb new file mode 100644 index 0000000000..5f396d9466 --- /dev/null +++ b/app/domain/responses.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +# These response objects provide a mechanism for passing more complex response +# information upstream + +# Responsible for handling "successful" requests. The +# response is returned via the `.result` method. +class SuccessResponse + attr_reader :result + + def initialize(result) + @result = result + end + + def success? + true + end + + # The result of bind should always be another Response object, if the current + # response object is successful, #bind will call the next operation + def bind(&_block) + yield(result) + end +end + +# Responsible for handling "failed" requests. +# Log level and Response code are both option. +class FailureResponse + attr_reader :message, :status, :exception, :backtrace + + def initialize(message, level: :warn, status: :unauthorized, exception: nil, backtrace: nil) + @message = message + @level = level + @status = status + @exception = exception + @backtrace = backtrace.nil? ? caller : backtrace # Add stack trace + end + + def success? + false + end + + def level + @level.to_sym + end + + def to_s + @message.to_s + end + + # If the current response is a failure, further attempts to bind will just + # return this response again. + def bind + self + end +end diff --git a/app/domain/roles/roles_handler.rb b/app/domain/roles/roles_handler.rb new file mode 100644 index 0000000000..7157a34460 --- /dev/null +++ b/app/domain/roles/roles_handler.rb @@ -0,0 +1,15 @@ +module RolesHandler + + def parse_role_id(role_id, v2_syntax: false) + if role_id.nil? || role_id.count(':') < 2 + raise Exceptions::InvalidRoleId, role_id + end + + account, _, rest = role_id.partition(':') + type, _, id = rest.partition(':') + + type = Util::V2Helpers.translate_kind(type) if v2_syntax + + { account: account, type: type, id: id } + end +end diff --git a/app/domain/secrets/cache/redis_handler.rb b/app/domain/secrets/cache/redis_handler.rb new file mode 100644 index 0000000000..5d2d8a2116 --- /dev/null +++ b/app/domain/secrets/cache/redis_handler.rb @@ -0,0 +1,196 @@ +# frozen_string_literal: true +module Secrets + module RedisHandler + + OK = 'OK' # Redis response for creation success + PREFIXES = { + secret: '', + resource: "secrets/resource/", + user: "user/", + role_membership: "{role_membership}/" # in {} to avoid Redis limitation for delete_matched on cluster. see https://redis.io/docs/latest/commands/keys/ + } + + #Secrets + def get_redis_secret(key, version = nil) + return nil, nil unless secret_applicable?(key) + versioned_key = versioned_key(key, version) + + value = read_secret(versioned_key) + mime_type = read_secret(key + '/mime_type') || SecretsController::DEFAULT_MIME_TYPE + # Returns non-nil value if found in Redis. mime_type is never nil + return value, mime_type + rescue => e + Rails.logger.error(LogMessages::Redis::RedisAccessFailure.new('Read', e.message)) + return nil, nil + end + + # Returns OK if no error occurred, not necessarily write in Redis + def create_redis_secret(key, value, mime_type, version = nil) + return OK unless secret_applicable?(key) + + versioned_key = versioned_key(key, version) + + value_res = write_secret(versioned_key, value) + if mime_type != SecretsController::DEFAULT_MIME_TYPE # We save mime_type only if it's not default + write_secret(key + '/mime_type', mime_type) + end + return value_res + rescue => e + Rails.logger.error(LogMessages::Redis::RedisAccessFailure.new('Write', e.message)) + return 'false' + end + + def delete_redis_secret(key) + return unless secret_applicable?(key) + + delete_from_redis(:secret, key) + rescue => e + Rails.logger.error(LogMessages::Redis::RedisAccessFailure.new('Delete', e.message)) + raise e + end + + # Resources + def get_redis_resource(resource_id) + return nil unless redis_configured? + read_resource(resource_id) + rescue => e + Rails.logger.error(LogMessages::Redis::RedisAccessFailure.new('Read resource', e.message)) + return nil + end + + def create_redis_resource(resource_id, value) + return OK unless redis_configured? + write_resource(resource_id, value) + rescue => e + Rails.logger.error(LogMessages::Redis::RedisAccessFailure.new('Write resource', e.message)) + return 'false' + end + + def delete_redis_resource(resource_id) + return unless redis_configured? + delete_from_redis(:resource, resource_id) + rescue => e + Rails.logger.error(LogMessages::Redis::RedisAccessFailure.new('Delete resource', e.message)) + raise e + end + + # Role membership + + # @param: role_id + # @param: block for retrieving membership from DB + def get_role_membership(role_id) + return yield unless redis_configured? + + role_membership = nil + begin + role_membership = read_from_redis(:role_membership, role_id) + rescue => e + Rails.logger.error(LogMessages::Redis::RedisAccessFailure.new('Read role membership', e.message)) + end + if role_membership.nil? + role_membership = yield + begin + write_to_redis(:role_membership, role_id, role_membership) + rescue => e + Rails.logger.error(LogMessages::Redis::RedisAccessFailure.new('Write role membership', e.message)) + end + end + role_membership + end + + def clean_membership_cache + begin + Rails.cache.delete_matched("#{PREFIXES[:role_membership]}*") if Rails.application.config.conjur_config.try(:conjur_edge_is_atlantis) + rescue => e + Rails.logger.error(LogMessages::Redis::RedisAccessFailure.new('Delete role membership', e.message)) + end + end + + ## User + def create_redis_user(role_id, value) + return OK unless redis_configured? + write_user(role_id, value) + rescue => e + Rails.logger.error(LogMessages::Redis::RedisAccessFailure.new('Write user', e.message)) + return 'false' + end + + def get_redis_user(role_id) + return nil unless redis_configured? + read_user(role_id) + rescue => e + Rails.logger.error(LogMessages::Redis::RedisAccessFailure.new('Read user', e.message)) + return nil + end + + def delete_redis_user(resource_id) + return unless redis_configured? + delete_from_redis(:user, resource_id) + rescue => e + Rails.logger.error(LogMessages::Redis::RedisAccessFailure.new('Delete user', e.message)) + raise e + end + + def redis_configured? + Rails.configuration.cache_store.include?(:redis_cache_store) + end + + private + + def versioned_key(key, version) + return version ? key + "?version=" + version : key + end + + def read_user(key) + read_from_redis(:user, key) + end + + def write_user(key, value) + write_to_redis(:user, key, value) + end + + def read_secret(key) + result = read_from_redis(:secret, key) + Slosilo::EncryptedAttributes.decrypt(result, aad: key) + end + + def write_secret(key, value) + write_to_redis(:secret, key, Slosilo::EncryptedAttributes.encrypt(value, aad: key)) + end + + def read_resource(key) + read_from_redis(:resource, key) + end + + def write_resource(key, value) + write_to_redis(:resource, key, value) + end + + def read_from_redis(type, key) + Rails.logger.debug{LogMessages::Redis::RedisAccessStart.new('Read')} + value = Rails.cache.read(PREFIXES[type] + key) + is_found = value.nil? ? "not " : "" + Rails.logger.debug{LogMessages::Redis::RedisAccessEnd.new('Read', "#{type} #{key} was #{is_found} in Redis")} + value + end + + def write_to_redis(type, key, value) + Rails.logger.debug{LogMessages::Redis::RedisAccessStart.new('Write')} + response = Rails.cache.write(PREFIXES[type] + key, value) + Rails.logger.debug{LogMessages::Redis::RedisAccessEnd.new("write #{type}", response)} + response + end + + def delete_from_redis(type, key) + Rails.logger.debug{LogMessages::Redis::RedisAccessStart.new('Delete')} + response = Rails.cache.delete(PREFIXES[type] + key) + Rails.logger.debug{LogMessages::Redis::RedisAccessEnd.new('Delete', "Deleted #{response} items")} + end + + def secret_applicable?(key) + redis_configured? && + key.split(':').last.start_with?('data') + end + + end +end diff --git a/app/domain/secrets/secret_types/aws_assume_role_dynamic_secret_type.rb b/app/domain/secrets/secret_types/aws_assume_role_dynamic_secret_type.rb new file mode 100644 index 0000000000..afdba98c1b --- /dev/null +++ b/app/domain/secrets/secret_types/aws_assume_role_dynamic_secret_type.rb @@ -0,0 +1,73 @@ +module Secrets + module SecretTypes + class AWSAssumeRoleDynamicSecretType < DynamicSecretType + DYNAMIC_ROLE_ARN = "dynamic/role-arn" + DYNAMIC_REGION = "dynamic/region" + DYNAMIC_POLICY = "dynamic/inline-policy" + + def method_params_as_json(annotations, json_result) + method_params = { + } + method_params = annotation_to_json_field(annotations, DYNAMIC_ROLE_ARN, "role_arn", method_params) + method_params = annotation_to_json_field(annotations, DYNAMIC_REGION, "region", method_params, false) + method_params = annotation_to_json_field(annotations, DYNAMIC_POLICY, "inline_policy", method_params, false) + json_result.merge(method_params: method_params) + end + + def create_input_validation(params) + super(params) + + input_validation(params) + end + + def update_input_validation(params, body_params) + secret = super(params, body_params) + input_validation(body_params) + secret + end + + private + + def convert_fields_to_annotations(params) + annotations = super(params) + method_params = params[:method_params] + add_annotation(annotations, DYNAMIC_ROLE_ARN, method_params[:role_arn]) + add_annotation(annotations, DYNAMIC_REGION, method_params[:region]) + add_annotation(annotations, DYNAMIC_POLICY, method_params[:inline_policy]) + annotations + end + + def input_validation(params) + method_params = params[:method_params] + if method_params.nil? + raise Errors::Conjur::ParameterMissing.new("method_params") + end + + data_fields = { + role_arn: { + field_info: { + type: String, + value: method_params[:role_arn] + }, + validators: [method(:validate_field_required), method(:validate_field_type), method(:validate_role_arn)] + }, + region: { + field_info: { + type: String, + value: method_params[:region] + }, + validators: [method(:validate_field_type), method(:validate_region)] + }, + inline_policy: { + field_info: { + type: String, + value: method_params[:inline_policy] + }, + validators: [method(:validate_field_type)] + } + } + validate_data_fields(data_fields) + end + end + end +end \ No newline at end of file diff --git a/app/domain/secrets/secret_types/aws_federation_token_dynamic_secret_type.rb b/app/domain/secrets/secret_types/aws_federation_token_dynamic_secret_type.rb new file mode 100644 index 0000000000..7449a19d6b --- /dev/null +++ b/app/domain/secrets/secret_types/aws_federation_token_dynamic_secret_type.rb @@ -0,0 +1,65 @@ +module Secrets + module SecretTypes + class AWSFederationTokenDynamicSecretType < DynamicSecretType + DYNAMIC_REGION = "dynamic/region" + DYNAMIC_POLICY = "dynamic/inline-policy" + + def create_input_validation(params) + super(params) + + input_validation(params) + end + + def update_input_validation(params, body_params) + secret = super(params, body_params) + input_validation(body_params) + secret + end + + def method_params_as_json(annotations, json_result) + method_params = { + } + method_params = annotation_to_json_field(annotations, DYNAMIC_REGION, "region", method_params, false) + method_params = annotation_to_json_field(annotations, DYNAMIC_POLICY, "inline_policy", method_params, false) + unless method_params.empty? + json_result = json_result.merge(method_params: method_params) + end + json_result + end + + private + def input_validation(params) + method_params = params[:method_params] + if method_params + data_fields = { + region: { + field_info: { + type: String, + value: method_params[:region] + }, + validators: [method(:validate_field_type), method(:validate_region)] + }, + inline_policy: { + field_info: { + type: String, + value: method_params[:inline_policy] + }, + validators: [method(:validate_field_type)] + } + } + validate_data_fields(data_fields) + end + end + + def convert_fields_to_annotations(params) + annotations = super(params) + method_params = params[:method_params] + if method_params + add_annotation(annotations, DYNAMIC_REGION, method_params[:region]) + add_annotation(annotations, DYNAMIC_POLICY, method_params[:inline_policy]) + end + annotations + end + end + end +end \ No newline at end of file diff --git a/app/domain/secrets/secret_types/dynamic_secret_type.rb b/app/domain/secrets/secret_types/dynamic_secret_type.rb new file mode 100644 index 0000000000..fd5f3cf718 --- /dev/null +++ b/app/domain/secrets/secret_types/dynamic_secret_type.rb @@ -0,0 +1,154 @@ +module Secrets + module SecretTypes + class DynamicSecretType < SecretBaseType + DYNAMIC_ISSUER = "dynamic/issuer" + DYNAMIC_TTL = "dynamic/ttl" + DYNAMIC_METHOD = "dynamic/method" + + def create_input_validation(params) + super(params) + + # check if value field exist + raise ApplicationController::UnprocessableEntity, "Adding value to a dynamic secret is not allowed" if params[:value] + # check the secret under the correct branch + branch = params[:branch] + if branch.start_with?("/") + branch = branch[1..-1] + end + raise ApplicationController::UnprocessableEntity, "Dynamic secrets must be created under #{Issuer::DYNAMIC_VARIABLE_PREFIX}" unless is_dynamic_branch(branch) + + dynamic_input_validation("#{branch}/#{params[:name]}", params) + end + + def update_input_validation(params, body_params) + secret = super(params, body_params) + + branch = params[:branch] + if branch.start_with?("/") + branch = branch[1..-1] + end + dynamic_input_validation("#{branch}/#{params[:name]}", body_params) + + secret + end + + def get_create_permissions(params) + permissions = super(params) + + #For Dynamic Secret - has 'use' permissions to issuer policy + issuer = params[:issuer] + issuer_policy = get_resource("policy", "conjur/issuers/#{issuer}") + issuer_permissions = {issuer_policy => :use} + + permissions.merge! issuer_permissions + end + + def get_update_permissions(params, secret) + permissions = super(params, secret) + + #For Ephemeral Secret - has 'use' permissions to issuer policy + issuer = params[:issuer] + issuer_policy = get_resource("policy", "conjur/issuers/#{issuer}") + issuer_permissions = {issuer_policy => :use} + + permissions.merge! issuer_permissions + end + + def collect_all_permissions(params) + allowed_privilege = %w[read execute] + collect_all_valid_permissions(params, allowed_privilege) + end + + def create_secret(branch, secret_name, params) + secret = super(branch, secret_name, params) + + as_json(branch, secret_name, secret) + rescue Sequel::UniqueConstraintViolation => e + raise Exceptions::RecordExists.new("secret", secret_id) + end + + def replace_secret(branch, secret_name, secret, params) + super(branch, secret, params) + + as_json(branch, secret_name, secret) + end + + def as_json(branch, name, variable) + # Create json result from branch and name + json_result = super(branch, name) + + # add the dynamic fields to the result + annotations = get_annotations(variable) + json_result = annotation_to_json_field(annotations, DYNAMIC_ISSUER, "issuer", json_result) + json_result = annotation_to_json_field(annotations, DYNAMIC_TTL, "ttl", json_result, false, true) + json_result = annotation_to_json_field(annotations, DYNAMIC_METHOD, "method", json_result) + + # get specific dynamic type + dynamic_secret_method = json_result[:method] + dynamic_secret_type = Secrets::SecretTypes::DynamicSecretTypeFactory.new.create_dynamic_secret_type(dynamic_secret_method) + json_result = dynamic_secret_type.method_params_as_json(annotations, json_result) + + # add annotations to json result + json_result = json_result.merge(annotations: annotations) + + # add permissions to json result + json_result = json_result.merge(permissions: get_permissions(variable)) + + json_result.to_json + end + + private + + def convert_fields_to_annotations(params) + annotations = super(params) + add_annotation(annotations, DYNAMIC_ISSUER, params[:issuer]) + add_annotation(annotations, DYNAMIC_TTL, params[:ttl]) + add_annotation(annotations, DYNAMIC_METHOD, params[:method]) + annotations + end + + def add_annotation(annotations, annotation_name, annotation_value) + if annotation_value + annotation = {} + annotation.store('name', annotation_name) + annotation.store('value', annotation_value) + annotations.push(annotation) + end + end + + def dynamic_input_validation(secret_name, params) + # check if value field exist + raise ApplicationController::UnprocessableEntity, "Adding value to a dynamic secret is not allowed" if params[:value] + + data_fields = { + issuer: { + field_info: { + type: String, + value: params[:issuer] + }, + validators: [method(:validate_field_required), method(:validate_field_type), method(:validate_id)] + }, + ttl: { + field_info: { + type: Integer, + value: params[:ttl] + }, + validators: [method(:validate_field_type), method(:validate_positive_integer)] + } + } + validate_data_fields(data_fields) + + # check if issuer exists + issuer_id = params[:issuer] + issuer = Issuer.where(issuer_id: issuer_id).first + raise Exceptions::RecordNotFound, "#{account}:issuer:#{issuer_id}" unless issuer + + begin + IssuerTypeFactory.new.create_issuer_type(issuer[:issuer_type]).validate_variable(secret_name, params[:method], params[:ttl], issuer) + rescue ArgumentError => e + raise ApplicationController::UnprocessableEntity, e.message + end + end + end + end +end diff --git a/app/domain/secrets/secret_types/dynamic_secret_type_factory.rb b/app/domain/secrets/secret_types/dynamic_secret_type_factory.rb new file mode 100644 index 0000000000..ca39a99846 --- /dev/null +++ b/app/domain/secrets/secret_types/dynamic_secret_type_factory.rb @@ -0,0 +1,15 @@ +module Secrets + module SecretTypes + class DynamicSecretTypeFactory + def create_dynamic_secret_type(method) + if !method.nil? && method.casecmp(AwsIssuerType::FEDERATION_TOKEN_METHOD).zero? + Secrets::SecretTypes::AWSFederationTokenDynamicSecretType.new + elsif !method.nil? && method.casecmp(AwsIssuerType::ASSUME_ROLE_METHOD).zero? + Secrets::SecretTypes::AWSAssumeRoleDynamicSecretType.new + else + raise ApplicationController::BadRequestWithBody, "Dynamic Secret method is unsupported" + end + end + end + end +end \ No newline at end of file diff --git a/app/domain/secrets/secret_types/secret_base_type.rb b/app/domain/secrets/secret_types/secret_base_type.rb new file mode 100644 index 0000000000..2f669e152b --- /dev/null +++ b/app/domain/secrets/secret_types/secret_base_type.rb @@ -0,0 +1,165 @@ +module Secrets + module SecretTypes + class SecretBaseType + include PermissionsHandler + include AnnotationsHandler + include ResourcesHandler + + def create_input_validation(params) + data_fields = { + name: { + field_info: { + type: String, + value: params[:name] + }, + validators: [method(:validate_field_required), method(:validate_field_type), method(:validate_id)] + }, + branch: { + field_info: { + type: String, + value: params[:branch] + }, + validators: [method(:validate_field_required), method(:validate_field_type)] + } + } + validate_data_fields(data_fields) + + # check policy exists + get_resource("policy", params[:branch]) + end + + def update_input_validation(params, body_params ) + #check branch and secret name are not part of body + raise ApplicationController::UnprocessableEntity, "'branch' is not allowed in the request body" if body_params[:branch] + raise ApplicationController::UnprocessableEntity, "'name' is not allowed in the request body" if body_params[:name] + + # check secret exists + get_resource("variable", "#{params[:branch]}/#{params[:name]}") + end + + def get_input_validation(params) + # check secret exists + branch = params[:branch] + secret_name = params[:name] + get_resource("variable", "#{branch}/#{secret_name}") + end + + def get_create_permissions(params) + policy = get_resource("policy", params[:branch]) + { policy => :update } + end + + def get_read_permissions(variable) + { variable => :read } + end + + def get_update_permissions(params, secret) + policy = get_resource("policy", params[:branch]) + { policy => :update } + end + + def create_secret(branch, secret_name, params) + policy_id = full_resource_id("policy", branch) + secret = create_resource(branch, secret_name, policy_id) + + # Add annotations + annotations = merge_annotations(params) + create_annotations(secret, policy_id, annotations) + + # Add permissions + resources_privileges = collect_all_permissions(params) + add_permissions(resources_privileges, secret.id, policy_id) + + secret + end + + def replace_secret(branch, secret, params) + # update annotations + annotations = merge_annotations(params) + # Remove all resource annotations + delete_resource_annotations(secret) + # Add all current annotations + policy_id = full_resource_id("policy", branch) + create_annotations(secret, policy_id, annotations) + + # update permissions + resources_privileges = collect_all_permissions(params) + # Remove all resource permissions + delete_resource_permissions(secret) + # Add all current permissions + add_permissions(resources_privileges, secret.id, policy_id) + end + + def as_json(branch, name) + # We want always return the full path syntax in response + unless branch.start_with?("/") + branch = "/#{branch}" + end + { + branch: branch, + name: name + } + end + + def is_dynamic_branch(branch) + # We want to make sure the branch start with full path of dynamic to prevent cases where the branch name contains dynamic + unless branch.end_with?("/") + branch = "#{branch}/" + end + branch.start_with?(Issuer::DYNAMIC_VARIABLE_PREFIX) + end + + private + + def create_resource(branch, secret_name, policy_id) + secret_id = full_resource_id("variable", "#{branch}/#{secret_name}") + # Create variable resource + ::Resource.create(resource_id: secret_id, owner_id: policy_id, policy_id: policy_id) + rescue Sequel::UniqueConstraintViolation => e + raise Exceptions::RecordExists.new("secret", secret_id) + end + + def merge_annotations(params) + annotations = convert_fields_to_annotations(params) + # add annotations from secret fields + params_annotations = params[:annotations] + if params_annotations + validate_annotations(params_annotations) + annotations.concat(params_annotations) + end + + annotations + end + + def convert_fields_to_annotations(params) + [] + end + + def collect_all_valid_permissions(params, allowed_privilege) + permissions = [] + if params[:permissions] + permissions = params[:permissions] + end + validate_permissions(permissions, allowed_privilege) + end + + def annotation_to_json_field(annotations, annotation_name, field_name, json_result, required=true, convert_to_int=false) + annotation_entity = annotations.find { |hash| hash[:name] == annotation_name } + annotation_value = nil + if annotation_entity + annotation_value = annotation_entity[:value] + if convert_to_int + annotation_value = annotation_value.to_i + end + annotations.delete(annotation_entity) + elsif required # If the field is required but there is no annotation for it we will set it as empty + annotation_value = "" + end + if annotation_value + json_result[field_name.to_sym] = annotation_value + end + json_result + end + end + end +end diff --git a/app/domain/secrets/secret_types/static_secret_type.rb b/app/domain/secrets/secret_types/static_secret_type.rb new file mode 100644 index 0000000000..6bdf722224 --- /dev/null +++ b/app/domain/secrets/secret_types/static_secret_type.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true +require_relative '../cache/redis_handler' + +module Secrets + module SecretTypes + class StaticSecretType < SecretBaseType + include ParamsValidator + include AnnotationsHandler + include RedisHandler + + MIME_TYPE_ANNOTATION = "conjur/mime_type" + + def get_input_validation(params) + secret = super(params) + raise ApplicationController::BadRequestWithBody, "Check the branch you have provided for your static secret. The #{Issuer::DYNAMIC_VARIABLE_PREFIX} branch is reserved for dynamic secrets only." if is_dynamic_branch(params[:branch]) + secret + end + + def update_input_validation(params, body_params ) + #check branch and secret name are not part of body + raise ApplicationController::UnprocessableEntity, "'branch' is not allowed in the request body" if body_params[:branch] + raise ApplicationController::UnprocessableEntity, "'name' is not allowed in the request body" if body_params[:name] + raise ApplicationController::BadRequestWithBody, "Check the branch you have provided for your static secret. The #{Issuer::DYNAMIC_VARIABLE_PREFIX} branch is reserved for dynamic secrets only." if params[:branch] && is_dynamic_branch(params[:branch]) + + # check secret exists + secret = get_resource("variable", "#{params[:branch]}/#{params[:name]}") + + data_fields = { + mime_type: { + field_info: { + type: String, + value: body_params[:mime_type] + }, + validators: [method(:validate_field_type), method(:validate_mime_type)] + } + } + validate_data_fields(data_fields) + + secret + end + + def create_input_validation(params) + super(params) + + data_fields = { + mime_type: { + field_info: { + type: String, + value: params[:mime_type] + }, + validators: [method(:validate_field_type), method(:validate_mime_type)] + } + } + validate_data_fields(data_fields) + + # Can't create the secret under dynamic branch + branch = params[:branch] + if branch.start_with?("/") + branch = branch[1..-1] + end + raise ApplicationController::UnprocessableEntity, "Choose a different branch under /data for your static secret. The #{Issuer::DYNAMIC_VARIABLE_PREFIX} branch is reserved for dynamic secrets only." if is_dynamic_branch(branch) + + if params[:issuer] + raise ApplicationController::UnprocessableEntity, "A static secret can't contain an 'issuer' field" + end + end + + def collect_all_permissions(params) + allowed_privilege = %w[read execute update] + collect_all_valid_permissions(params, allowed_privilege) + end + + def create_secret(branch, secret_name, params) + secret = super(branch, secret_name, params) + + # Set secret value + set_value(secret, params[:value]) + + as_json(branch, secret_name, secret) + rescue Sequel::UniqueConstraintViolation => e + raise Exceptions::RecordExists.new("secret", secret_id) + end + + def replace_secret(branch, secret_name, secret, params) + super(branch, secret, params) + + # Set secret value + set_value(secret, params[:value]) + + as_json(branch, secret_name, secret) + end + + def as_json(branch, name, variable) + # Create json result from branch and name + json_result = super(branch, name) + + # add the static fields to the result + annotations = get_annotations(variable) + json_result = annotation_to_json_field(annotations, MIME_TYPE_ANNOTATION, "mime_type", json_result, false) + + # add annotations to json result + json_result = json_result.merge(annotations: annotations) + + # add permissions to json result + json_result = json_result.merge(permissions: get_permissions(variable)) + + json_result.to_json + end + + def get_update_permissions(params, secret) + permissions = super(params, secret) + + # Update permissions on the secret + secret_permissions = {secret => :update} + + permissions.merge! secret_permissions + end + + private + + + def set_value(secret, value) + unless value.nil? || value.empty? + ::DB::Service::SecretService.instance.secret_value_change(secret.id, value) + end + end + + def convert_fields_to_annotations(params) + mime_type_annotation = nil + if params[:mime_type] + mime_type_annotation = {} + mime_type_annotation.store('name', MIME_TYPE_ANNOTATION) + mime_type_annotation.store('value', params[:mime_type]) + end + + annotations = [] + if mime_type_annotation + annotations.push(mime_type_annotation) + end + annotations + end + end + end +end diff --git a/app/domain/token_factory.rb b/app/domain/token_factory.rb index 14d09ce5a3..98e7ccb4af 100644 --- a/app/domain/token_factory.rb +++ b/app/domain/token_factory.rb @@ -12,19 +12,28 @@ class TokenFactory < Dry::Struct MAXIMUM_AUTHENTICATION_TOKEN_EXPIRATION = 5.hours MINIMUM_AUTHENTICATION_TOKEN_EXPIRATION = 0 - def signing_key(account) - slosilo["authn:#{account}".to_sym] || raise(NoSigningKey, account) + def signing_key(username, account) + sloislo_key = host?(username) ? Account.token_key(account, "host") : Account.token_key(account, "user") + sloislo_key || raise(NoSigningKey, Account.token_id(account, host?(username) ? "host" : "user")) end def signed_token(account:, username:, host_ttl: Rails.application.config.conjur_config.host_authorization_token_ttl, user_ttl: Rails.application.config.conjur_config.user_authorization_token_ttl) - signing_key(account).issue_jwt( + # Issue a JWT will auto-populate iat (issued at). However, this is done by + # calling Time.now again, which can lead to discrepancies between the + # expected and actual JWT lifespan. To avoid this, we capture the current + # time once, and use it to provide both the iat and exp values for the + # token. + iat = Time.now + signing_key(username, account).issue_jwt( + iat: iat, sub: username, - exp: Time.now + offset( - ttl: username.starts_with?('host/') ? host_ttl : user_ttl - ) + exp: iat + offset( + ttl: host?(username) ? host_ttl : user_ttl + ), + tid: Rails.application.config.conjur_config.tenant_id ) end @@ -42,4 +51,8 @@ def parse_ttl(ttl:) # Attempt to coerce a string into integer ttl.to_s.to_i end + + def host?(username) + username.start_with?('host/') + end end diff --git a/app/domain/util/concurrency_limited_cache.rb b/app/domain/util/concurrency_limited_cache.rb index 6fbff88d3c..3219dc2382 100644 --- a/app/domain/util/concurrency_limited_cache.rb +++ b/app/domain/util/concurrency_limited_cache.rb @@ -28,20 +28,20 @@ def call(**args) cache_key = cache_key(args) @concurrency_mutex.synchronize do if @concurrent_requests >= @max_concurrent_requests - @logger.debug( + @logger.debug{ LogMessages::Util::ConcurrencyLimitedCacheReached.new(@max_concurrent_requests) - ) + } raise Errors::Util::ConcurrencyLimitReachedBeforeCacheInitialization unless @cache.key?(cache_key) return @cache[cache_key] end @concurrent_requests += 1 - @logger.debug( + @logger.debug{ LogMessages::Util::ConcurrencyLimitedCacheConcurrentRequestsUpdated.new( @concurrent_requests ) - ) + } end @semaphore.synchronize do @@ -54,7 +54,7 @@ def call(**args) def recalculate(args, cache_key) @cache[cache_key] = @target.call(**args) - @logger.debug(LogMessages::Util::ConcurrencyLimitedCacheUpdated.new) + @logger.debug{LogMessages::Util::ConcurrencyLimitedCacheUpdated.new} decrease_concurrent_requests rescue => e decrease_concurrent_requests @@ -64,11 +64,11 @@ def recalculate(args, cache_key) def decrease_concurrent_requests unless @concurrent_requests.zero? @concurrent_requests -= 1 - @logger.debug( + @logger.debug{ LogMessages::Util::ConcurrencyLimitedCacheConcurrentRequestsUpdated.new( @concurrent_requests ) - ) + } end end @@ -76,11 +76,11 @@ def decrease_concurrent_requests def cache_key(args) if args.key?(:cache_key) cache_key = args.fetch(:cache_key) - @logger.debug( + @logger.debug{ LogMessages::Util::ConcurrencyLimitedCacheKeyRetrieved.new( cache_key ) - ) + } else cache_key = args end diff --git a/app/domain/util/contract_utils.rb b/app/domain/util/contract_utils.rb new file mode 100644 index 0000000000..cc8139ba72 --- /dev/null +++ b/app/domain/util/contract_utils.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Util + class ContractUtils + class << self + def failed_response(error:, key:) + key.failure(exception: error, text: error.message) + end + end + end +end diff --git a/app/domain/util/error_class.rb b/app/domain/util/error_class.rb index 11b8f7b49c..20e5bced16 100644 --- a/app/domain/util/error_class.rb +++ b/app/domain/util/error_class.rb @@ -6,10 +6,13 @@ module Util class ErrorClass - def self.new(msg) - Class.new(RuntimeError) do - def initialize(*args) + def self.new(msg, base_error_class: RuntimeError) + Class.new(base_error_class) do + def initialize(*args, **kwargs) @args = args + kwargs.each do |name, value| + instance_variable_set("@#{name}", value) + end end define_method(:to_s) do @args.each_with_index.reduce(msg) do |m, (x, arg_index)| diff --git a/app/domain/util/open_ssl/x509/certificate.rb b/app/domain/util/open_ssl/x509/certificate.rb index 0aafd434db..3ebbf3b692 100644 --- a/app/domain/util/open_ssl/x509/certificate.rb +++ b/app/domain/util/open_ssl/x509/certificate.rb @@ -30,8 +30,8 @@ def self.from_hash( cert.not_before = now cert.not_after = now + good_for.to_i cert.public_key = public_key - cert.serial = SecureRandom.random_number(2**160) # 20 bytes - cert.version = 2 + cert.serial = serial + cert.version = version ef = OpenSSL::X509::ExtensionFactory.new ef.subject_certificate = cert @@ -46,7 +46,21 @@ def self.from_hash( # Create basic cert with defaults quickly # - def self.from_subject(subject:, key: nil, issuer: nil, alt_name: nil) + def self.from_subject( + subject:, + key: nil, + issuer: nil, + alt_name: nil, + good_for: 10.years, + extensions: [ + # The format is [name, value, critical?], critical is optional and + # defaults to false + ['basicConstraints', 'CA:TRUE', true], + ['keyUsage', 'keyCertSign', true], + ['subjectKeyIdentifier', 'hash'], + ['authorityKeyIdentifier', 'keyid:always,issuer:always'] + ] + ) key ||= OpenSSL::PKey::RSA.new(2048) issuer ||= subject @@ -54,12 +68,8 @@ def self.from_subject(subject:, key: nil, issuer: nil, alt_name: nil) subject: subject, issuer: issuer, public_key: key.public_key, - good_for: 10.years, - extensions: [ - ['basicConstraints', 'CA:TRUE', true], - %w[subjectKeyIdentifier hash], - ['authorityKeyIdentifier', 'keyid:always,issuer:always'] - ] + alt_name_ext(alt_name) + good_for: good_for, + extensions: Array(extensions) + alt_name_ext(alt_name) ) cert.sign(key, OpenSSL::Digest.new('SHA256')) cert diff --git a/app/domain/util/param_validator.rb b/app/domain/util/param_validator.rb new file mode 100644 index 0000000000..15bfa18e18 --- /dev/null +++ b/app/domain/util/param_validator.rb @@ -0,0 +1,25 @@ +module Util + def self.validate_data(data, data_fields, params_count) + data_fields.each do |field_symbol, field_type| + # The field exists in data + if data[field_symbol].nil? + raise Errors::Conjur::ParameterMissing.new(field_symbol.to_s) + end + + # The field is of correct type + unless data[field_symbol].is_a?(field_type) + raise Errors::Conjur::ParameterTypeInvalid.new(field_symbol.to_s, field_type.to_s) + end + + # The field value is not empty + if data[field_symbol].empty? + raise Errors::Conjur::ParameterMissing.new(field_symbol.to_s) + end + end + + # We don't have more fields then expected + if data.keys.count != params_count + raise Errors::Conjur::NumOfParametersInvalid.new(data_fields.keys.join(", ")) + end + end +end \ No newline at end of file diff --git a/app/domain/util/rate_limited_cache.rb b/app/domain/util/rate_limited_cache.rb index befa064dae..8c9f601fe3 100644 --- a/app/domain/util/rate_limited_cache.rb +++ b/app/domain/util/rate_limited_cache.rb @@ -55,16 +55,16 @@ def call(**args) def recalculate(args, cache_key) if too_many_requests?(cache_key) - @logger.debug( + @logger.debug{ LogMessages::Util::RateLimitedCacheLimitReached.new( @refreshes_per_interval, @rate_limit_interval ) - ) + } return end @cache[cache_key] = @target.call(**args) - @logger.debug(LogMessages::Util::RateLimitedCacheUpdated.new) + @logger.debug{LogMessages::Util::RateLimitedCacheUpdated.new} @refresh_history[cache_key].push(@time.now) end @@ -72,11 +72,11 @@ def cached_key(args) if args.key?(:cache_key) cache_key = args.fetch(:cache_key) args.delete(:cache_key) - @logger.debug( + @logger.debug{ LogMessages::Util::RateLimitedCacheKeyRetrieved.new( cache_key ) - ) + } else cache_key = args end diff --git a/app/domain/util/static_account.rb b/app/domain/util/static_account.rb new file mode 100644 index 0000000000..0158e3b50a --- /dev/null +++ b/app/domain/util/static_account.rb @@ -0,0 +1,10 @@ +module StaticAccount + def self.account + @account ||= "conjur" + @account + end + + def self.set_account(account) + @account = account + end +end \ No newline at end of file diff --git a/app/domain/util/time_expired_cache.rb b/app/domain/util/time_expired_cache.rb new file mode 100644 index 0000000000..e40f0cb2ad --- /dev/null +++ b/app/domain/util/time_expired_cache.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true +module Util + #This class is used to cut back server calls by caching the results of a block for a given amount of time. + class TimeExpiredCache + def initialize( + interval= 60, + &block + ) + @cached_result = nil + @result_expires_at = Time.at(0) + @block = block + @interval = interval + end + + def result + if @cached_result.nil? || @result_expires_at < Time.now + @cached_result = @block.call + @result_expires_at = Time.now + @interval + end + @cached_result + end + end +end diff --git a/app/domain/util/trackable_error_class.rb b/app/domain/util/trackable_error_class.rb index 78341baca7..d090f2fad6 100644 --- a/app/domain/util/trackable_error_class.rb +++ b/app/domain/util/trackable_error_class.rb @@ -6,8 +6,8 @@ # module Util class TrackableErrorClass - def self.new(msg:, code:) - ErrorClass.new("#{code} #{msg}") + def self.new(msg:, code:, **kwargs) # **kwargs can contain only base_error_class: + ErrorClass.new("#{code} #{msg}", **kwargs) end end end diff --git a/app/domain/util/v2_helpers.rb b/app/domain/util/v2_helpers.rb new file mode 100644 index 0000000000..88b0cb9952 --- /dev/null +++ b/app/domain/util/v2_helpers.rb @@ -0,0 +1,13 @@ +module Util + module V2Helpers + def self.translate_kind(kind) + case kind + when 'host' + kind = 'workload' + when 'variable' + kind = 'secret' + end + kind + end + end +end diff --git a/app/models/account.rb b/app/models/account.rb index 7daf1ddb11..a5fcda9ea0 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true +require_relative '../../gems/conjur-rack/lib/conjur/rack/consts' Account = Struct.new(:id) do class << self @@ -17,15 +18,24 @@ def find_or_create_accounts_resource INVALID_ID_CHARS = /[ :]/.freeze + def token_key(account, role, tag = "current") + Slosilo[token_id(account, role, tag)] + end + + def token_id(account, role, tag = "current") + "authn:#{account}:#{role}:#{tag}" + end + def create(id, owner_id = nil) - raise Exceptions::RecordExists.new("account", id) if Slosilo["authn:#{id}"] + raise Exceptions::RecordExists.new("account", id) if token_key(id, "host") || token_key(id, "user") if (invalid = INVALID_ID_CHARS.match(id)) raise ArgumentError, 'account name "%s" contains invalid characters (%s)' % [id, invalid] end Role.db.transaction do - Slosilo["authn:#{id}"] = Slosilo::Key.new + Slosilo[token_id(id, "host")] = Slosilo::Key.new + Slosilo[token_id(id, "user")] = Slosilo::Key.new role_id = "#{id}:user:admin" admin_user = Role.create(role_id: role_id) @@ -40,34 +50,35 @@ def create(id, owner_id = nil) end def list - accounts = [] - Slosilo.each do |k,v| - accounts << k - end - accounts.map do |account| - account =~ /\Aauthn:(.+)\z/ - $1 - end.delete_if do |account| - account == "!" + account_set = Set.new + Slosilo.each do |account,_| + account =~ Conjur::Rack::Consts::TOKEN_ID_REGEX + account_set.add($1) unless $1 == "!" end + account_set end end - def token_key - Slosilo["authn:#{id}"] + def token_key(role) + Account.token_key(id, role) + end + + def token_id(role) + Account.token_id(id, role) end def delete # Ensure the signing key exists - slosilo_keystore.adapter.model.with_pk!("authn:#{id}") - + slosilo_keystore.adapter.model.with_pk!(token_id("user")) + slosilo_keystore.adapter.model.with_pk!(token_id("host")) Role["#{id}:user:admin"].destroy Role["#{id}:policy:root"].try(:destroy) Resource["#{id}:user:admin"].try(:destroy) Credentials.where(Sequel.lit("account(role_id)") => id).delete Secret.where(Sequel.lit("account(resource_id)") => id).delete - slosilo_keystore.adapter.model["authn:#{id}"].destroy - + slosilo_keystore.adapter.model[token_id("user")].destroy + slosilo_keystore.adapter.model[token_id("host")].destroy + Rails.cache.clear true end diff --git a/app/models/activity_log.rb b/app/models/activity_log.rb new file mode 100644 index 0000000000..0d90aa9fa8 --- /dev/null +++ b/app/models/activity_log.rb @@ -0,0 +1,3 @@ +class ActivityLog < Sequel::Model(:activity_log) + def_column_alias :id, :activity_id +end diff --git a/app/models/annotation.rb b/app/models/annotation.rb index 2d8d1b6c4a..05120062da 100644 --- a/app/models/annotation.rb +++ b/app/models/annotation.rb @@ -8,7 +8,7 @@ class Annotation < Sequel::Model unrestrict_primary_key many_to_one :resource, reciprocal: :annotations - + many_to_one :role, :key => :role_id, :class => :Role def as_json options = {} options[:except] ||= [] options[:except].push(:resource_id) diff --git a/app/models/audit/event/edge/creds_generation.rb b/app/models/audit/event/edge/creds_generation.rb new file mode 100644 index 0000000000..eb48ddd147 --- /dev/null +++ b/app/models/audit/event/edge/creds_generation.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true +require_relative 'edge_install_base' + +module Audit + module Event + class CredsGeneration < EdgeInstallBase + def message_id + "creds-generated" + end + + def operation + "create" + end + + def success_message + "User #{@user} successfully generated installation token for Edge named #{@edge_name}" + end + + def failure_message + "User #{@user} failed to generate token for Edge instance #{@edge_name}" + end + + end + end +end \ No newline at end of file diff --git a/app/models/audit/event/edge/edge_creation.rb b/app/models/audit/event/edge/edge_creation.rb new file mode 100644 index 0000000000..3828202cdd --- /dev/null +++ b/app/models/audit/event/edge/edge_creation.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true +require_relative 'edge_install_base' + +module Audit + module Event + class EdgeCreation < EdgeInstallBase + def message_id + "created" + end + + def operation + "create" + end + + def success_message + "User #{@user} successfully created new Edge instance named #{@edge_name}" + end + + def failure_message + "User #{@user} failed to create new Edge instance named #{@edge_name}" + end + end + end +end diff --git a/app/models/audit/event/edge/edge_deletion.rb b/app/models/audit/event/edge/edge_deletion.rb new file mode 100644 index 0000000000..e30188c4bc --- /dev/null +++ b/app/models/audit/event/edge/edge_deletion.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true +require_relative 'edge_install_base' + +module Audit + module Event + class EdgeDeletion < EdgeInstallBase + def message_id + "deleted" + end + + def operation + "delete" + end + + def success_message + "User #{@user} successfully deleted Edge instance named #{@edge_name}" + end + + def failure_message + "User #{@user} failed to delete Edge instance named #{@edge_name}" + end + end + end +end diff --git a/app/models/audit/event/edge/edge_install_base.rb b/app/models/audit/event/edge/edge_install_base.rb new file mode 100644 index 0000000000..c595b59283 --- /dev/null +++ b/app/models/audit/event/edge/edge_install_base.rb @@ -0,0 +1,96 @@ +module Audit + module Event + # NOTE: Breaking this class up further would harm clarity. + # :reek:TooManyInstanceVariables and :reek:TooManyParameters + class EdgeInstallBase + + def initialize( + edge_name:, + user: nil, + client_ip: nil, + error_message: nil + ) + @edge_name = edge_name + @user = user + @client_ip = client_ip + @error_message = error_message + end + + # NOTE: We want this class to be responsible for providing `progname`. + # At the same time, `progname` is currently always "conjur" and this is + # unlikely to change. Moving `progname` into the constructor now + # feels like premature optimization, so we ignore reek here. + # :reek:UtilityFunction + def progname + Event.progname + end + + def severity + Syslog::LOG_NOTICE + end + + def to_s + message + end + + # TODO: See issue https://github.com/cyberark/conjur/issues/1608 + # :reek:NilCheck + def message + attempted_action.message( + success_msg: success_message, + failure_msg: failure_message, + error_msg: @error_message + ) + end + + def message_id + raise NotImplementedError, "Subclasses must implement abstract_method." + end + + def operation + raise NotImplementedError, "Subclasses must implement abstract_method." + end + + def attempted_action + @attempted_action ||= AttemptedAction.new( + success: success?, + operation: operation + ) + end + + # TODO: See issue https://github.com/cyberark/conjur/issues/1608 + # :reek:NilCheck + def structured_data + { + SDID::AUTH => { user: @user }, + SDID::SUBJECT => {edge: @edge_name}, + SDID::CLIENT => { ip: @client_ip } + }.merge( + attempted_action.action_sd + ) + end + + def facility + # Security or authorization messages which should be kept private. See: + # https://github.com/ruby/ruby/blob/b753929806d0e42cdfde3f1a8dcdbf678f937e44/ext/syslog/syslog.c#L109 + # Note: Changed this to from LOG_AUTH to LOG_AUTHPRIV because the former + # is deprecated. + Syslog::LOG_AUTHPRIV + end + + private + + def success_message + raise NotImplementedError, "Subclasses must implement abstract_method." + end + + def failure_message + raise NotImplementedError, "Subclasses must implement abstract_method." + end + + def success? + @error_message.nil? + end + end + end +end diff --git a/app/models/audit/event/edge/edge_startup.rb b/app/models/audit/event/edge/edge_startup.rb new file mode 100644 index 0000000000..cbedd55c34 --- /dev/null +++ b/app/models/audit/event/edge/edge_startup.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Audit + module Event + class EdgeStartup < EdgeInstallBase + def message_id + "installed" + end + + def operation + "install" + end + + def success_message + "Edge instance #{@edge_name} has been installed" + end + + def failure_message + "Edge instance #{@edge_name} install failed" + end + + end + end +end \ No newline at end of file diff --git a/app/models/audit/event/issuer.rb b/app/models/audit/event/issuer.rb new file mode 100644 index 0000000000..012550192c --- /dev/null +++ b/app/models/audit/event/issuer.rb @@ -0,0 +1,101 @@ +module Audit + module Event + # NOTE: Breaking this class up further would harm clarity. + # :reek:TooManyInstanceVariables and :reek:TooManyParameters + class Issuer + + attr_reader :message_id + + def initialize( + user_id:, + client_ip:, + subject:, + message_id:, + success:, + operation:, + error_message: nil + ) + @user_id = user_id + @subject = subject + @client_ip = client_ip + @success = success + @operation = operation + @message_id=message_id + @error_message = error_message + + # Implements `==` for audit events + @comparable_evt = ComparableEvent.new(self) + end + + # NOTE: We want this class to be responsible for providing `progname`. + # At the same time, `progname` is currently always "conjur" and this is + # unlikely to change. Moving `progname` into the constructor now + # feels like premature optimization, so we ignore reek here. + # :reek:UtilityFunction + def progname + Event.progname + end + + # action_sd means "action structured data" + def action_sd + attempted_action.action_sd + end + + def severity + attempted_action.severity + end + + def to_s + message + end + + def message + if @operation == "list" + attempted_action.message( + success_msg: "#{@user_id} listed issuers #{@subject[:account]}:issuer:#{@subject[:issuer]}", + failure_msg: "#{@user_id} tried to list issuers #{@subject[:account]}:issuer:#{@subject[:issuer]}", + error_msg: @error_message + ) + else + past_tense_verb = "#{@operation.to_s.chomp('e')}ed" + attempted_action.message( + success_msg: "#{@user_id} #{past_tense_verb} #{@subject[:account]}:issuer:#{@subject[:issuer]}", + failure_msg: "#{@user_id} tried to #{@operation} #{@subject[:account]}:issuer:#{@subject[:issuer]}", + error_msg: @error_message + ) + end + end + + def structured_data + { + SDID::AUTH => { user: @user_id }, + SDID::SUBJECT => @subject, + SDID::CLIENT => { ip: @client_ip } + }.merge( + attempted_action.action_sd + ) + end + + def facility + # Security or authorization messages which should be kept private. See: + # https://github.com/ruby/ruby/blob/master/ext/syslog/syslog.c#L109 + # Note: Changed this to from LOG_AUTH to LOG_AUTHPRIV because the former + # is deprecated. + Syslog::LOG_AUTHPRIV + end + + def ==(other) + @comparable_evt == other + end + + private + + def attempted_action + @attempted_action ||= AttemptedAction.new( + success: @success, + operation: @operation + ) + end + end + end +end diff --git a/app/models/audit/event/issuer_variable.rb b/app/models/audit/event/issuer_variable.rb new file mode 100644 index 0000000000..8280300b44 --- /dev/null +++ b/app/models/audit/event/issuer_variable.rb @@ -0,0 +1,95 @@ +module Audit + module Event + # NOTE: Breaking this class up further would harm clarity. + # :reek:TooManyInstanceVariables and :reek:TooManyParameters + class IssuerVariable + + def initialize( + user_id:, + client_ip:, + subject:, + message_id:, + success:, + operation:, + error_message: nil + ) + @user_id = user_id + @client_ip = client_ip + @subject = subject + @message_id = message_id + @success = success + @operation = operation + @error_message = error_message + end + + # NOTE: We want this class to be responsible for providing `progname`. + # At the same time, `progname` is currently always "conjur" and this is + # unlikely to change. Moving `progname` into the constructor now + # feels like premature optimization, so we ignore reek here. + # :reek:UtilityFunction + def progname + Event.progname + end + + def severity + # NOTE: original, incorrect logic, just in case we need it: + # + # @success ? Syslog::LOG_NOTICE : Syslog::LOG_WARNING + # + # The incorrect part was that a success was logged as LOG_NOTICE rather + # than LOG_INFO. This comment added June 10, 2020. Future developers + # may safely remove it if more than a couple months have elapsed. + attempted_action.severity + end + + def to_s + message + end + + def message + attempted_action.message( + success_msg: "#{@subject[:resource_id]} removed as a result of the removal of #{@subject[:account]}:issuer:#{@subject[:issuer]}", + failure_msg: "failed to remove #{@subject[:resource_id]}, following the removal of #{@subject[:account]}:issuer:#{@subject[:issuer]}", + error_msg: @error_message + ) + end + + def message_id + @message_id + end + + def structured_data + { + SDID::AUTH => { user: @user_id }, + SDID::SUBJECT => @subject, + SDID::CLIENT => { ip: @client_ip } + }.merge( + attempted_action.action_sd + ) + end + + def facility + # Security or authorization messages which should be kept private. See: + # https://github.com/ruby/ruby/blob/b753929806d0e42cdfde3f1a8dcdbf678f937e44/ext/syslog/syslog.c#L109 + # Note: Changed this to from LOG_AUTH to LOG_AUTHPRIV because the former + # is deprecated. + Syslog::LOG_AUTHPRIV + end + + # action_sd means "action structured data" + def action_sd + attempted_action.action_sd + end + + private + + def attempted_action + @attempted_action ||= AttemptedAction.new( + success: @success, + operation: @operation + ) + end + + end + end +end diff --git a/app/models/audit/event/policy.rb b/app/models/audit/event/policy.rb index 658feed5b0..3c1e6f94cb 100644 --- a/app/models/audit/event/policy.rb +++ b/app/models/audit/event/policy.rb @@ -85,6 +85,14 @@ def facility Syslog::LOG_AUTHPRIV end + def subject + @subject + end + + def operation + @operation + end + private def success_message diff --git a/app/models/audit/event/synchronizer/synchronizer_creation.rb b/app/models/audit/event/synchronizer/synchronizer_creation.rb new file mode 100644 index 0000000000..9978d4caed --- /dev/null +++ b/app/models/audit/event/synchronizer/synchronizer_creation.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true +require_relative 'synchronizer_install_base' + +module Audit + module Event + class SynchronizerCreation < SynchronizerInstallBase + def message_id + "created" + end + + def operation + "create" + end + + def success_message + "User #{@user} successfully created new Synchronizer instance" + end + + def failure_message + "User #{@user} failed to create new Synchronizer instance" + end + + end + + end +end + diff --git a/app/models/audit/event/synchronizer/synchronizer_install_base.rb b/app/models/audit/event/synchronizer/synchronizer_install_base.rb new file mode 100644 index 0000000000..67c7e9603e --- /dev/null +++ b/app/models/audit/event/synchronizer/synchronizer_install_base.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +module Audit + module Event + class SynchronizerInstallBase + def initialize( + synchronizer_id: nil, + user: nil, + client_ip: nil, + error_message: nil + ) + @synchronizer_id = synchronizer_id + @user = user + @client_ip = client_ip + @error_message = error_message + end + + # NOTE: We want this class to be responsible for providing `progname`. + # At the same time, `progname` is currently always "conjur" and this is + # unlikely to change. Moving `progname` into the constructor now + # feels like premature optimization, so we ignore reek here. + # :reek:UtilityFunction + def progname + Event.progname + end + + def severity + Syslog::LOG_NOTICE + end + + def to_s + message + end + + def message + attempted_action.message( + success_msg: success_message, + failure_msg: failure_message, + error_msg: @error_message + ) + end + + def message_id + raise NotImplementedError, "Subclasses must implement abstract_method." + end + + def operation + raise NotImplementedError, "Subclasses must implement abstract_method." + end + + def attempted_action + @attempted_action ||= AttemptedAction.new( + success: success?, + operation: operation + ) + end + + # TODO: See issue https://github.com/cyberark/conjur/issues/1608 + # :reek:NilCheck + def structured_data + { + SDID::AUTH => { user: @user }, + SDID::SUBJECT => {synchronizer: @synchronizer_id}, + SDID::CLIENT => { ip: @client_ip } + }.merge( + attempted_action.action_sd + ) + end + + def facility + # Security or authorization messages which should be kept private. See: + # https://github.com/ruby/ruby/blob/b753929806d0e42cdfde3f1a8dcdbf678f937e44/ext/syslog/syslog.c#L109 + # Note: Changed this to from LOG_AUTH to LOG_AUTHPRIV because the former + # is deprecated. + Syslog::LOG_AUTHPRIV + end + + private + + def success_message + raise NotImplementedError, "Subclasses must implement abstract_method." + end + + def failure_message + raise NotImplementedError, "Subclasses must implement abstract_method." + end + + def success? + @error_message.nil? + end + + end + end +end + diff --git a/app/models/audit/event/synchronizer/token_generation.rb b/app/models/audit/event/synchronizer/token_generation.rb new file mode 100644 index 0000000000..111aad8f84 --- /dev/null +++ b/app/models/audit/event/synchronizer/token_generation.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +require_relative 'synchronizer_install_base' + +module Audit + module Event + + class TokenGeneration < SynchronizerInstallBase + def message_id + "creds-generated" + end + + def operation + "create" + end + + def success_message + "User #{@user} successfully generated installation token for Synchronizer named #{@synchronizer_id}" + end + + def failure_message + "User #{@user} failed to generate token for Synchronizer instance #{@synchronizer_id}" + end + end + + end +end + + diff --git a/app/models/audit/event/v2_resource.rb b/app/models/audit/event/v2_resource.rb new file mode 100644 index 0000000000..e10616d109 --- /dev/null +++ b/app/models/audit/event/v2_resource.rb @@ -0,0 +1,116 @@ +module Audit + module Event + # NOTE: Breaking this class up further would harm clarity. + # :reek:TooManyInstanceVariables and :reek:TooManyParameters + class V2Resource + + def initialize( + operation:, + resource_type:, + resource_name:, + request_path:, + request_body: nil, + user: nil, + client_ip: nil, + error_message: nil + ) + @operation = operation + @resource_type = resource_type + @resource_name = resource_name + @request_path = request_path + @request_body = request_body + @user = user + @client_ip = client_ip + @error_message = error_message + end + + # NOTE: We want this class to be responsible for providing `progname`. + # At the same time, `progname` is currently always "conjur" and this is + # unlikely to change. Moving `progname` into the constructor now + # feels like premature optimization, so we ignore reek here. + # :reek:UtilityFunction + def progname + Event.progname + end + + def severity + Syslog::LOG_NOTICE + end + + def to_s + message + end + + # TODO: See issue https://github.com/cyberark/conjur/issues/1608 + # :reek:NilCheck + def message + attempted_action.message( + success_msg: success_message, + failure_msg: failure_message, + error_msg: @error_message + ) + end + + def message_id + @resource_type + end + + def operation + @operation + end + + def attempted_action + @attempted_action ||= AttemptedAction.new( + success: success?, + operation: operation + ) + end + + # TODO: See issue https://github.com/cyberark/conjur/issues/1608 + # :reek:NilCheck + def structured_data + { + SDID::AUTH => { user: @user }, + SDID::SUBJECT => {edge: @edge_name}, + SDID::CLIENT => { ip: @client_ip } + }.merge( + attempted_action.action_sd + ) + end + + def facility + # Security or authorization messages which should be kept private. See: + # https://github.com/ruby/ruby/blob/b753929806d0e42cdfde3f1a8dcdbf678f937e44/ext/syslog/syslog.c#L109 + # Note: Changed this to from LOG_AUTH to LOG_AUTHPRIV because the former + # is deprecated. + Syslog::LOG_AUTHPRIV + end + + private + + def success_message + past_tense_verb = "#{@operation.to_s.chomp('e')}ed" + if @operation == 'get' + past_tense_verb = "retrieved" + elsif @operation == 'change' + past_tense_verb = "updated" + end + "#{@user} successfully #{past_tense_verb} #{@resource_type} #{@resource_name} with URI path: '#{@request_path}'#{@request_body? " and JSON object: #{@request_body}":""}" + end + + def failure_message + action = @operation + if @operation == 'get' + action = "retrieve" + elsif @operation == 'change' + action = "update" + end + "#{@user} failed to #{action} #{@resource_type} #{@resource_name} with URI path: '#{@request_path}'#{@request_body? " and JSON object: #{@request_body}":""}" + end + + def success? + @error_message.nil? + end + end + end +end diff --git a/app/models/audit/log/syslog_adapter.rb b/app/models/audit/log/syslog_adapter.rb index 2a89309377..27769a79c2 100644 --- a/app/models/audit/log/syslog_adapter.rb +++ b/app/models/audit/log/syslog_adapter.rb @@ -28,7 +28,9 @@ def log(event) # so we provide the correct Ruby severity that the "log" interface # expects. severity = RubySeverity.new(event.severity) - @ruby_logger.log(severity, event, ::Audit::Event.progname) + Mutex.new.synchronize do + @ruby_logger.log(severity, event, ::Audit::Event.progname) + end end end end diff --git a/app/models/audit/subject.rb b/app/models/audit/subject.rb index 1c384d0de2..c09fc185ac 100644 --- a/app/models/audit/subject.rb +++ b/app/models/audit/subject.rb @@ -24,6 +24,12 @@ class Resource < Subject to_s { format "resource %s", resource_id } end + class Issuer < Subject + field :issuer_id + to_h {{ resource: issuer_id }} + to_s { format "issuer %s", issuer_id } + end + class Role < Subject field :role_id to_h {{ role: role_id }} diff --git a/app/models/authenticator.rb b/app/models/authenticator.rb new file mode 100644 index 0000000000..162bc3faaf --- /dev/null +++ b/app/models/authenticator.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +class Authenticator + class << self + + def get_property(id) + begin + record = Secret.where(:resource_id.like(id)).last.value + record + rescue + '' + end + end + def jwt + authn_jwt_regex = "webservice:conjur/authn-jwt/[^/]+$" # valid : conjur/authn-jwt/myVendor, not valid : conjur/authn-jwt/myVendor/status + variable_jwt_prefix = "variable:conjur/authn-jwt/" + resources_with_authenticator_configs = "SELECT resource_id, enabled + FROM authenticator_configs" + + # # join between 3 tables : authn_configs, resources, permissions + # # for getting resource_id, list of permissions for each resource_id and enabled + # # if there is no record_id in authn_configs -> enabled will be false + # # also, we will select resource_id only if it contains authn_jwt_prefix + shared_properties = "SELECT resources.resource_id, + jsonb_agg(jsonb_build_object( + 'privilege', permissions.privilege, + 'role_id', permissions.role_id)) AS permissions, + COALESCE(authn_configs.enabled, false) AS enabled + FROM resources + LEFT JOIN permissions ON resources.resource_id = permissions.resource_id + LEFT JOIN + (#{resources_with_authenticator_configs}) AS authn_configs ON resources.resource_id = authn_configs.resource_id + WHERE resources.resource_id ~ '#{authn_jwt_regex}' + GROUP BY resources.resource_id, authn_configs.enabled" + + # extract all properties (secrets) that belongs to some authn-jwt + # on theirs latest version + unique_properties = "SELECT + r.resource_id AS property_id, + COALESCE(s.version, 1) AS version + FROM resources r + LEFT JOIN ( + SELECT + resource_id, + MAX(version) AS max_version + FROM secrets + GROUP BY + resource_id + ) subquery ON r.resource_id = subquery.resource_id + LEFT JOIN secrets s ON r.resource_id = s.resource_id AND subquery.max_version = s.version + WHERE r.resource_id LIKE '%#{variable_jwt_prefix}%'" + + # split_part(Shared.resource_id, '/', 3) = split_part(UniqueProps.property_id, '/', 3) + # explanation : each resource_id has some UniqueProps.property_id , we want to join by the following format + # cucumber:variable:conjur/authn-jwt/X/Y == cucumber:webservice:conjur/authn-jwt/X + # conjur/authn-jwt/X --> is the shared part to be joined by + scope = Sequel::Model.db.fetch(%{ + WITH Shared AS ( + #{shared_properties} + ), + UniqueProps AS ( + #{unique_properties} + ) + SELECT + Shared.resource_id, + Shared.permissions, + Shared.enabled, + jsonb_agg(jsonb_build_object( + 'property_id', UniqueProps.property_id)) AS claims + FROM Shared + LEFT JOIN UniqueProps ON split_part(Shared.resource_id, '/', 3) = split_part(UniqueProps.property_id, '/', 3) + GROUP BY Shared.resource_id, Shared.permissions, Shared.enabled + ORDER BY Shared.resource_id + }) + scope + end + end +end \ No newline at end of file diff --git a/app/models/authn_local.rb b/app/models/authn_local.rb index b3957bc837..71f8d772bd 100644 --- a/app/models/authn_local.rb +++ b/app/models/authn_local.rb @@ -31,6 +31,8 @@ def run server = UNIXServer.new(socket) + # Allow user and group permissions on this socket + File.chmod(0o770, socket) trap(0) do # remove the socket on exit # alternatively it can be removed on startup @@ -67,8 +69,8 @@ def issue_token claims claims = claims.slice("account", "sub", "exp", "cidr") (account = claims.delete("account")) || raise("'account' is required") raise "'sub' is required" unless claims['sub'] - - key = Slosilo["authn:#{account}"] + username = claims['sub'] + key = username.starts_with?('host/') ? Account.token_key(account, "host") : Account.token_key(account, "user") if key key.issue_jwt(claims).to_json else diff --git a/app/models/credentials.rb b/app/models/credentials.rb index 0ce92f9658..a5ed8da53f 100644 --- a/app/models/credentials.rb +++ b/app/models/credentials.rb @@ -37,7 +37,8 @@ def as_json end def restricted_to - self[:restricted_to].map { |cidr| Util::CIDR.new(cidr) } + # Ensure restricted_to is an array. This allows us to mock Roles without persisting them. + [*self[:restricted_to]].map { |cidr| Util::CIDR.new(cidr) } end def password= pwd @@ -66,14 +67,17 @@ def valid_password? pwd def valid_api_key? key return false if expired? - + if api_key.nil? + Rails.logger.warn("No api key exists for this role") + return false + end key && ActiveSupport::SecurityUtils.secure_compare(key, api_key) end def validate super - validates_presence([ :api_key ]) + validates_presence([ :api_key ]) if self.role.api_key_expected? # We intentionally don't validate when there is no password # See flow in Account.create @@ -89,11 +93,25 @@ def validate def before_validation super - self.api_key ||= self.class.random_api_key + self.api_key ||= self.class.random_api_key if self.role.api_key_expected? + end + + def api_key + # api_key is set to 'APIKEY' by trigger in case authn/api-key is set to true + # In this case, we want to rotate the api key to a real value + if self[:api_key] == 'APIKEY' + rotate_api_key + save_changes + end + super() end def rotate_api_key - self.api_key = self.class.random_api_key + if self.role.api_key_expected? + self.api_key = self.class.random_api_key + else + raise ::Errors::Conjur::ApiKeyNotFound.new(self.role.role_id) + end end private diff --git a/app/models/edge.rb b/app/models/edge.rb new file mode 100644 index 0000000000..a2bb0a3c23 --- /dev/null +++ b/app/models/edge.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true +require 'securerandom' +require 'sequel' + +class Edge < Sequel::Model + + include HostAuthentication + + EDGE_HOST_PATTERN = "ACCOUNT:host:edge/edge-IDENTIFIER/edge-host-IDENTIFIER" + EDGE_INSTALLER_HOST_PATTERN = "ACCOUNT:host:edge/edge-installer-IDENTIFIER/edge-installer-host-IDENTIFIER" + class << self + + def new_edge(**values) + raise ArgumentError, 'max allowed edges not provided' unless values[:max_edges] + # extract max_edges from values and delete it from the data to be inserted + max_edges = values.delete(:max_edges) + raise ArgumentError, 'Edge name is not provided' unless values[:name] + values[:id] ||= SecureRandom.uuid + begin + # Acquire the lock on the table (lock is released automatically at the end of the transaction) + Sequel::Model.db.execute("LOCK TABLE edges IN ACCESS EXCLUSIVE MODE NOWAIT") + table_size = Edge.count + # Add a check for the maximum allowed limit + raise ApplicationController::UnprocessableEntity, "Edge number exceeded max edge allowed #{max_edges}" unless table_size < max_edges.to_i + Edge.insert(**values) + rescue Sequel::UniqueConstraintViolation => e + raise Exceptions::RecordExists.new("edge", values[:name]) + end + end + + def get_by_hostname(hostname) + Edge.where(id: hostname_to_id(hostname)).first || raise(Exceptions::RecordNotFound.new(hostname, + message: "Edge for host #{hostname} not found")) + end + + def get_name_by_hostname(hostname) + begin + get_by_hostname(hostname)[:name] + rescue KeyError => e + # Only happens if there issue with ORM, happened to us in production when we used as a method .name instead of [:name] + Rails.logger.error("Didn't find attribute name in edge: #{e.message}") + "" + rescue Exceptions::RecordNotFound + "" # Return empty string in case the edge instance is not in DB + end + end + + def hostname_to_id(hostname) + regex = Regexp.new(EDGE_HOST_PATTERN.sub("ACCOUNT", '\w+').gsub("IDENTIFIER","(.+)")) + hostname.match(regex)&.captures&.first + end + + end + + def record_edge_access(data, ip) + self.ip = ip + self.version = data['edge_version'] if data['edge_version'] + sync_time = Time.at(data['edge_statistics']['last_synch_time']) # This field is required + self.last_sync = sync_time if sync_time.to_i > 0 + self.platform = data['edge_container_type'] if data['edge_container_type'] + + self.save + end + + def get_edge_host_name(account) + EDGE_HOST_PATTERN.sub("ACCOUNT", account).gsub("IDENTIFIER", self.id) + end + + def get_edge_installer_host_name(account) + EDGE_INSTALLER_HOST_PATTERN.sub("ACCOUNT", account).gsub("IDENTIFIER", self.id) + end + + def get_installer_token(account, request) + installer_host_full_name = self.get_edge_installer_host_name(account) + get_access_token(account, installer_host_full_name, request) + end + +end diff --git a/app/models/event.rb b/app/models/event.rb new file mode 100644 index 0000000000..50a91bc2cf --- /dev/null +++ b/app/models/event.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +class Event < Sequel::Model + + + class << self + + def create_event(event_type:, event_value:) + begin + Event.create(event_type: event_type, event_value: event_value) + rescue Sequel::DatabaseError => e + if e.cause.is_a?(PG::InvalidTextRepresentation) + error_msg = "create_event failed on invalid input. event_value must be a valid json. " + e.message + Rails.logger.error(error_msg) + raise ApplicationController::InternalServerError, error_msg + else + raise ApplicationController::InternalServerError, e.message + end + end + end + + # Returns all events grouped by transaction_id and sorted by event_id + # e.g. {{trans1: [{event1}, {events}]}, {trans2}: [{event3}], ...} + def get_all_events_grouped + db_result = Event.order(:event_id).all + + # Transform to Ruby objects + ruby_pairs = [] + db_result.each do |row| + ruby_pairs << [row[:transaction_id], row] + end + ruby_pairs.group_by(&:first).transform_values { |values| values.map(&:last) } + end + + # @param: event_id can be either a single id or an array of ids + def delete_by_id(event_id) + Event.where(event_id: event_id).delete + end + + def delete_by_transaction_id(transaction_id) + Event.where(transaction_id: transaction_id).delete + end + + def unique_transaction_ids_count + Event.distinct.count(:transaction_id) + end + end +end diff --git a/app/models/exceptions/invalid_resource_id.rb b/app/models/exceptions/invalid_resource_id.rb new file mode 100644 index 0000000000..9c5a3a49b7 --- /dev/null +++ b/app/models/exceptions/invalid_resource_id.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Exceptions + class InvalidResourceId < RuntimeError + def initialize resource_id + super("Invalid resource ID: #{resource_id}. Valid IDs must be :account:kind:id.") + end + end +end diff --git a/app/models/exceptions/invalid_role_id.rb b/app/models/exceptions/invalid_role_id.rb new file mode 100644 index 0000000000..6194540e06 --- /dev/null +++ b/app/models/exceptions/invalid_role_id.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Exceptions + class InvalidRoleId < RuntimeError + def initialize role_id + super("Invalid role ID: #{role_id}. Valid IDs must be account:kind:id") + end + end +end + diff --git a/app/models/exceptions/method_not_allowed.rb b/app/models/exceptions/method_not_allowed.rb new file mode 100644 index 0000000000..96cbef2218 --- /dev/null +++ b/app/models/exceptions/method_not_allowed.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module Exceptions + class MethodNotAllowed < RuntimeError + end +end \ No newline at end of file diff --git a/app/models/exceptions/record_exists.rb b/app/models/exceptions/record_exists.rb index 57321e13d8..ba46625cfe 100644 --- a/app/models/exceptions/record_exists.rb +++ b/app/models/exceptions/record_exists.rb @@ -5,7 +5,7 @@ class RecordExists < RuntimeError attr_reader :kind, :id def initialize kind, id - super("#{kind} #{id.inspect} already exists") + super("#{kind} '#{id}' already exists") @kind = kind @id = id diff --git a/app/models/functions.rb b/app/models/functions.rb index 9336c63aa4..924da2fc03 100644 --- a/app/models/functions.rb +++ b/app/models/functions.rb @@ -127,5 +127,71 @@ def drop_version_trigger_sql(table) DROP FUNCTION IF EXISTS #{table}_next_version(); COUNTER_TRIGGER end + + def create_authn_ann_trigger_sql(primary_schema) + <<-ANNOTATION_SET + CREATE OR REPLACE FUNCTION insert_api_key_by_annotation() + RETURNS TRIGGER AS $$ + BEGIN + IF NEW.name = 'authn/api-key' AND NEW.value = 'true' THEN + UPDATE #{primary_schema}.credentials + SET api_key = 'APIKEY' + WHERE role_id = NEW.resource_id; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql STRICT; + CREATE TRIGGER insert_api_key_by_annotation_trigger + AFTER INSERT ON annotations + FOR EACH ROW + EXECUTE FUNCTION insert_api_key_by_annotation(); + + CREATE OR REPLACE FUNCTION update_api_key_by_annotation() + RETURNS TRIGGER AS $$ + BEGIN + IF OLD.name = 'authn/api-key' AND NEW.name = 'authn/api-key' THEN + IF OLD.value = 'true' AND NEW.value != 'true' THEN + UPDATE #{primary_schema}.credentials + SET api_key = NULL + WHERE role_id = OLD.resource_id; + ELSIF OLD.value != 'true' AND NEW.value = 'true' THEN + UPDATE #{primary_schema}.credentials + SET api_key = 'APIKEY' + WHERE role_id = OLD.resource_id; + END IF; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql STRICT; + CREATE TRIGGER update_api_key_by_annotation_trigger + AFTER UPDATE ON annotations + FOR EACH ROW + EXECUTE FUNCTION update_api_key_by_annotation(); + + CREATE OR REPLACE FUNCTION delete_api_key_by_annotation() + RETURNS TRIGGER AS $$ + BEGIN + IF OLD.name = 'authn/api-key' AND OLD.value = 'true' THEN + UPDATE #{primary_schema}.credentials + SET api_key = NULL + WHERE role_id = OLD.resource_id; + END IF; + RETURN OLD; + END; + $$ LANGUAGE plpgsql STRICT; + CREATE TRIGGER delete_api_key_by_annotation_trigger + BEFORE DELETE ON annotations + FOR EACH ROW + EXECUTE FUNCTION delete_api_key_by_annotation(); + ANNOTATION_SET + end + + def drop_authn_anno_trigger_sql + <<-ANNOTATION_SET + DROP TRIGGER IF EXISTS insert_api_key_by_annotation_trigger ON annotations; + DROP TRIGGER IF EXISTS update_api_key_by_annotation_trigger ON annotations; + DROP TRIGGER IF EXISTS delete_api_key_by_annotation_trigger ON annotations; + ANNOTATION_SET + end end end diff --git a/app/models/issuer.rb b/app/models/issuer.rb new file mode 100644 index 0000000000..420531be10 --- /dev/null +++ b/app/models/issuer.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'json' +require_relative '../controllers/wrappers/policy_wrapper' +require_relative '../controllers/wrappers/policy_audit' +require_relative '../controllers/wrappers/templates_renderer' + +class Issuer < Sequel::Model + + DYNAMIC_ANNOTATION_PREFIX = "dynamic/" + DYNAMIC_VARIABLE_PREFIX = "data/dynamic/" + + attr_encrypted :data, aad: :issuer_id + + unrestrict_primary_key + + def as_json + { + id: self.issuer_id, + max_ttl: self.max_ttl, + type: self.issuer_type, + data: JSON.parse(self.data), + created_at: self.created_at, + modified_at: self.modified_at + } + end + + def as_json_for_list + { + id: self.issuer_id, + max_ttl: self.max_ttl, + type: self.issuer_type, + created_at: self.created_at, + modified_at: self.modified_at + } + end + + def delete_issuer_variables + # Find all the variables that belong to the account and start with the ephemrals prefix + resource_ids = related_variables_query.select_map(:resource_id) + Resource.where(resource_id: resource_ids).delete + + resource_ids + end + + def issuer_variables_exist? + !related_variables_query.empty? + end + + private + + def related_variables_query + Annotation.where(Sequel.lit("resource_id LIKE ? AND value = ? AND name = ?", + "#{self.account}:variable:#{DYNAMIC_VARIABLE_PREFIX}%", + self.issuer_id, "#{DYNAMIC_ANNOTATION_PREFIX}issuer")) + end +end diff --git a/app/models/loader/create_policy.rb b/app/models/loader/create_policy.rb index f3aac3dc67..d287e08655 100644 --- a/app/models/loader/create_policy.rb +++ b/app/models/loader/create_policy.rb @@ -24,5 +24,9 @@ def call def new_roles @loader.new_roles end + + def self.authorize(current_user, resource) + # No-op + end end end diff --git a/app/models/loader/handlers/public_key.rb b/app/models/loader/handlers/public_key.rb index ffe36cc152..71838c6125 100644 --- a/app/models/loader/handlers/public_key.rb +++ b/app/models/loader/handlers/public_key.rb @@ -20,7 +20,7 @@ def store_public_keys id, public_key = entry resource = Resource[id] existing_secret = resource.last_secret - ::Secret.create(resource: resource, value: public_key.strip) unless existing_secret && existing_secret.value == public_key + ::DB::Service::SecretService.instance.secret_value_change(id, public_key.strip) unless existing_secret && existing_secret.value == public_key end end diff --git a/app/models/loader/modify_policy.rb b/app/models/loader/modify_policy.rb index 1ae91cf91a..7f51a1a66f 100644 --- a/app/models/loader/modify_policy.rb +++ b/app/models/loader/modify_policy.rb @@ -16,9 +16,11 @@ def call @loader.delete_shadowed_and_duplicate_rows - @loader.update_changed + @loader.upsert_policy_records - @loader.store_policy_in_db + @loader.clean_db + + @loader.store_auxiliary_data @loader.release_db_connection end @@ -26,5 +28,9 @@ def call def new_roles @loader.new_roles end + + def self.authorize(current_user, resource) + # No-op + end end end diff --git a/app/models/loader/orchestrate.rb b/app/models/loader/orchestrate.rb index b855bb5ccf..fcb260473f 100644 --- a/app/models/loader/orchestrate.rb +++ b/app/models/loader/orchestrate.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'conjur/extension/repository' +require_relative '../../domain/secrets/cache/redis_handler' # Loads a policy into the database, by operating on a PolicyVersion which has already been created with the policy id, # policy text, the authenticated user, and the policy owner. The PolicyVersion also parses the policy @@ -58,6 +59,7 @@ class Orchestrate include Handlers::RestrictedTo include Handlers::Password include Handlers::PublicKey + include Secrets::RedisHandler attr_reader :policy_version, :create_records, :delete_records, :new_roles, :schemata @@ -107,8 +109,6 @@ def setup_db_for_new_policy @extensions.call(:before_load_policy, policy_version: @policy_version) end - perform_deletion - create_schema load_records @@ -129,6 +129,123 @@ def store_policy_in_db drop_schema + perform_deletion + + store_passwords + + store_public_keys + + store_restricted_to + end + + def stash_new_roles + pk_columns = Array(Sequel::Model(:roles).primary_key) + pk_columns_with_policy_id = pk_columns + [ :policy_id ] + join_columns = pk_columns_with_policy_id.map do |c| + "public_roles.#{c} = new_roles.#{c}" + end.join(" AND ") + + # getting newly added roles + new_roles_sql = <<-SQL + SELECT * + FROM #{schema_name}.roles new_roles + WHERE NOT EXISTS ( + SELECT 1 + FROM #{primary_schema}.roles public_roles + WHERE ( #{join_columns} ) + ); + SQL + + new_roles_dataset = db.fetch(new_roles_sql) + @new_roles = new_roles_dataset.map{ |ds| Role.new(ds) } + end + + def upsert_policy_records + if @feature_flags.enabled?(:policy_load_extensions) + @extensions.call( + :before_insert, + policy_version: @policy_version, + schema_name: schema_name + ) + @extensions.call( + :before_update, + policy_version: @policy_version, + schema_name: schema_name + ) + end + + # We need to get newly created roles and save them in the class field before further changes. + # This is the last place we can do it. + stash_new_roles + + in_primary_schema do + TABLES.each do |table| + # Preparation for moving newly added entries from temporary + # schema to the public schema + pk_columns = Array(Sequel::Model(table).primary_key) + pk_columns_with_policy_id = pk_columns + [ :policy_id ] + + join_columns = pk_columns_with_policy_id.map do |c| + "#{table}.#{c} = new_#{table}.#{c}" + end.join(" AND ") + insert_columns = (TABLE_EQUIVALENCE_COLUMNS[table] + [ :policy_id ]).join(", ") + + # Preparing columns to be used during update. + # Value for policy_id will not be changed during update + # but for readability (one generic flow) and consistency with the rest of the code + # we are using list of columns with policy_id + update_columns = TABLE_EQUIVALENCE_COLUMNS[table] - pk_columns + [:policy_id] + update_statements = update_columns.map do |c| + "#{c} = new_#{table}.#{c}" + end.join(", ") + + db.execute(<<-UPSERT) + WITH inserted AS ( + INSERT INTO #{table} (#{insert_columns}) + SELECT #{insert_columns} + FROM #{schema_name}.#{table} new_#{table} + ON CONFLICT (#{pk_columns.join(', ')}) DO NOTHING + RETURNING #{pk_columns.join(', ')} + ) + UPDATE #{table} + SET #{update_statements} + FROM #{schema_name}.#{table} new_#{table} + WHERE NOT EXISTS ( + SELECT 1 + FROM inserted i + WHERE #{pk_columns.map do |c| + "i.#{c} = #{table}.#{c}" + end.join(' AND ')} + ) AND #{join_columns} + UPSERT + end + end + + # We want to use the if statement here to wrap the feature flag check + # rubocop:disable Style/GuardClause + if @feature_flags.enabled?(:policy_load_extensions) + @extensions.call( + :after_insert, + policy_version: @policy_version, + schema_name: schema_name + ) + @extensions.call( + :after_update, + policy_version: @policy_version, + schema_name: schema_name + ) + end + + # rubocop:enable Style/GuardClause + end + + def clean_db + drop_schema + + perform_deletion + end + + def store_auxiliary_data store_passwords store_public_keys @@ -193,18 +310,23 @@ def comparisons table, columns, existing_alias, new_alias end.join(' AND ') end - db[<<-DELETE, policy_version.resource_id].delete + deleted_ds = db[<<-DELETE, policy_version.resource_id] WITH deleted_records AS ( - SELECT existing_#{table}.* - FROM #{qualify_table(table)} AS existing_#{table} - LEFT OUTER JOIN #{table} AS new_#{table} - ON #{comparisons(table, columns, 'existing_', 'new_')} - WHERE existing_#{table}.policy_id = ? AND new_#{table}.#{columns[0]} IS NULL - ) - DELETE FROM #{qualify_table(table)} - USING deleted_records AS deleted_from_#{table} - WHERE #{comparisons(table, columns, "#{primary_schema}.", 'deleted_from_')} + DELETE FROM #{qualify_table(table)} + USING ( + SELECT existing_#{table}.* + FROM #{qualify_table(table)} AS existing_#{table} + LEFT OUTER JOIN #{table} AS new_#{table} + ON #{comparisons(table, columns, 'existing_', 'new_')} + WHERE existing_#{table}.policy_id = ? AND new_#{table}.#{columns[0]} IS NULL + ) AS deleted_from_#{table} + WHERE #{comparisons(table, columns, "#{primary_schema}.", 'deleted_from_')} + RETURNING * + ) + SELECT * FROM deleted_records DELETE + deleted_records = deleted_ds.all # Performs deletion and returns deleted records + post_process_deleted_records(deleted_records, table) unless deleted_records.empty? end # We want to use the if statement here to wrap the feature flag check @@ -325,9 +447,7 @@ def insert_new @new_roles = ::Role.all in_primary_schema do - disable_policy_log_trigger TABLES.each { |table| insert_table_records(table) } - enable_policy_log_trigger end # We want to use the if statement here to wrap the feature flag check @@ -345,46 +465,6 @@ def insert_new def insert_table_records(table) columns = (TABLE_EQUIVALENCE_COLUMNS[table] + [ :policy_id ]).join(", ") db.run("INSERT INTO #{table} ( #{columns} ) SELECT #{columns} FROM #{schema_name}.#{table}") - - # For large policies, the policy logging triggers occupy the majority - # of the policy load time. To make this more efficient on the initial - # load, we disable the triggers and update the policy log in bulk. - insert_policy_log_records(table) - end - - def disable_policy_log_trigger - # To disable the triggers during the bulk load we use a local - # configuration setting that the trigger function is aware of. - # When we set this variable to `true`, then the trigger will - # observe the setting value and skip its own policy log. - db.run('SET LOCAL conjur.skip_insert_policy_log_trigger = true') - end - - def enable_policy_log_trigger - db.run('SET LOCAL conjur.skip_insert_policy_log_trigger = false') - end - - def insert_policy_log_records(table) - primary_key_columns = Array(Sequel::Model(table).primary_key).map(&:to_s).pg_array - db.run(<<-POLICY_LOG) - INSERT INTO policy_log( - policy_id, - version, - operation, - kind, - subject) - SELECT - (policy_log_record( - '#{table}', - #{db.literal(primary_key_columns)}, - hstore(#{table}), - #{db.literal(policy_id)}, - #{db.literal(policy_version[:version])}, - 'INSERT' - )).* - FROM - #{schema_name}.#{table} - POLICY_LOG end # A random schema name. @@ -422,13 +502,13 @@ def perform_deletion # rubocop:enable Style/GuardClause end + $primary_schema = "public" + # Loads the records into the temporary schema (since the schema search path # contains only the temporary schema). def load_records raise "Policy version must be saved before loading" unless policy_version.resource_id - create_records.map(&:create!) - db[:role_memberships].where(admin_option: nil).update(admin_option: false) db[:role_memberships].where(ownership: nil).update(ownership: false) TABLES.each do |table| @@ -451,15 +531,14 @@ def in_primary_schema &block # Creates a set of tables in the new schema to mirror the tables in the primary schema. # The new tables are not created with constraints, aside from primary keys. def create_schema + $primary_schema = db.search_path + db.execute("CREATE SCHEMA #{schema_name}") db.search_path = schema_name - TABLES.each do |table| db.execute("CREATE TABLE #{table} AS SELECT * FROM #{qualify_table(table)} WHERE 0 = 1") end - db.execute(Functions.ownership_trigger_sql) - db.execute(<<-SQL_STATEMENT) CREATE OR REPLACE FUNCTION account(id text) RETURNS text LANGUAGE sql IMMUTABLE @@ -518,6 +597,17 @@ def db def release_db_connection Sequel::Model.db.disconnect end + + private + + def post_process_deleted_records(deleted_records, table) + # Delete secrets from Redis + if table == :resources + deleted_records.map { |r| Resource.new.set(r) } + .select { |r| r.kind == 'variable' } + .each { |r| delete_redis_secret(r.resource_id) } + end + end end # rubocop:enable Metrics/ClassLength end diff --git a/app/models/loader/replace_policy.rb b/app/models/loader/replace_policy.rb index e8fcbdb5e4..3fe2620be3 100644 --- a/app/models/loader/replace_policy.rb +++ b/app/models/loader/replace_policy.rb @@ -18,9 +18,11 @@ def call @loader.delete_shadowed_and_duplicate_rows - @loader.update_changed + @loader.upsert_policy_records - @loader.store_policy_in_db + @loader.clean_db + + @loader.store_auxiliary_data @loader.release_db_connection end @@ -28,5 +30,18 @@ def call def new_roles @loader.new_roles end + + def self.authorize(current_user, resource) + return if current_user.policy_permissions?(resource, 'update') + + Rails.logger.debug{ + Errors::Authentication::Security::RoleNotAuthorizedOnPolicyDescendants.new( + current_user.role_id, + 'update', + resource.resource_id + ) + } + raise ApplicationController::Forbidden + end end end diff --git a/app/models/loader/types.rb b/app/models/loader/types.rb index d86658c0ac..f950ae92f8 100644 --- a/app/models/loader/types.rb +++ b/app/models/loader/types.rb @@ -1,7 +1,11 @@ # frozen_string_literal: true +require_relative '../../controllers/concerns/authorize_resource' +require_relative '../../domain/secrets/cache/redis_handler' +require_relative '../../domain/issuers/issuer_types/issuer_type_factory' module Loader module Types + class << self def find_or_create_root_policy(account) ::Resource[root_policy_id(account)] || create_root_policy(account) @@ -105,16 +109,38 @@ def resource class Record < Types::Base include CreateRole include CreateResource + include AuthorizeResource + + @current_schema = "" + + def lookup_primary + @current_schema = Sequel::Model.db.search_path + Sequel::Model.db.search_path = $primary_schema + end + + def lookup_current + Sequel::Model.db.search_path = @current_schema + end def verify message = "Verify method for entity #{self} does not exist" raise Exceptions::InvalidPolicyObject.new(self.id, message: message) end + def auth_resource(privilege, resource_id, issuer_id, account) + resource = ::Resource[resource_id] + unless current_user.allowed_to?(privilege, resource) + issuer_exception_id = "#{account}:issuer:#{issuer_id}" + raise Exceptions::RecordNotFound, issuer_exception_id + end + end + def calculate_defaults!; end def create! + lookup_primary verify + lookup_current calculate_defaults! create_role! if policy_object.respond_to?(:roleid) create_resource! if policy_object.respond_to?(:resourceid) @@ -136,34 +162,7 @@ def verify; end class Host < Record def_delegators :@policy_object, :restricted_to - # This is a temporary policy validation check to ensure that we're not - # creating hosts that will fail API key-based authentication by default - # in the future. - def future_api_key_auth_will_fail? - # The default config value is to allow API key authentication, so if this is - # either the default or set to true, then future API key authentication will - # continue to work and we don't need to reject this policy. - return false if Rails.application.config.conjur_config.authn_api_key_default - - # If the default API authentication config is to disallow it, and the host - # does not explicitly state the policy authors intentions with the - # `authn/api-key` annotation with value true, then we should reject this until the annotation - # is added to the policy object. - self.annotations&.[]("authn/api-key").nil? || self.annotations["authn/api-key"].to_s.casecmp?("false") - end - - def verify - # If policy contains a host with annotation authn/api-key effectively false, either by explicit - # value or by default value, then policy load is blocked. - if future_api_key_auth_will_fail? - message = "API key authentication for hosts is disabled by default and " \ - "will be removed in a future release. Add the 'authn/api-key' " \ - "annotation to this host with the value 'true' to " \ - "ensure authentication works as expected for this host in the " \ - "future." - raise Exceptions::InvalidPolicyObject.new(self.id, message: message) - end - end + def verify; end def create! self.handle_restricted_to(self.roleid, restricted_to) @@ -212,6 +211,7 @@ def identifier end class Group < Record + include Secrets::RedisHandler def_delegators :@policy_object, :gidnumber def verify; end @@ -221,6 +221,7 @@ def create! self.annotations["conjur/gidnumber"] ||= self.gidnumber if self.gidnumber super + clean_membership_cache end end @@ -283,7 +284,43 @@ class Variable < Record def_delegators :@policy_object, :kind, :mime_type - def verify; end + def verify + if self.id.start_with?(Issuer::DYNAMIC_VARIABLE_PREFIX) + if self.annotations[Issuer::DYNAMIC_ANNOTATION_PREFIX + "issuer"].nil? + message = "The dynamic variable '#{self.id}' has no issuer annotation" + raise Exceptions::InvalidPolicyObject.new(self.id, message: message) + else + issuer_id = self.annotations[Issuer::DYNAMIC_ANNOTATION_PREFIX + "issuer"] + + issuer = Issuer.where(account: @policy_object.account, issuer_id: issuer_id).first + if (issuer.nil?) + issuer_exception_id = "#{@policy_object.account}:issuer:#{issuer_id}" + raise Exceptions::RecordNotFound, issuer_exception_id + end + + if self.annotations["#{Issuer::DYNAMIC_ANNOTATION_PREFIX}method"].nil? + raise Exceptions::InvalidPolicyObject.new(self.id, message: "The variable definition for dynamic secret \"#{self.id}\" requires a 'method' annotation.") + end + + begin + IssuerTypeFactory.new.create_issuer_type(issuer[:issuer_type]).validate_variable( + self.id, self.annotations["#{Issuer::DYNAMIC_ANNOTATION_PREFIX}method"], + annotations["#{Issuer::DYNAMIC_ANNOTATION_PREFIX}ttl"], + issuer) + rescue ArgumentError => e + raise Exceptions::InvalidPolicyObject.new(self.id, message: e.message) + end + + resource_id = @policy_object.account + ":policy:conjur/issuers/" + issuer_id + auth_resource(:use, resource_id,issuer_id,@policy_object.account) + end + else + if !(self.annotations.nil?) && !(self.annotations[Issuer::DYNAMIC_ANNOTATION_PREFIX + "issuer"].nil?) + message = "The dynamic variable '#{self.id}' is not in the correct path" + raise Exceptions::InvalidPolicyObject.new(self.id, message: message) + end + end + end def create! self.annotations ||= {} @@ -302,11 +339,13 @@ def verify; end end class Grant < Types::Base + include Secrets::RedisHandler def_delegators :@policy_object, :roles, :members def create! Array(roles).each do |r| Array(members).each do |m| + verify(r, m) ::RoleMembership.create( role_id: find_roleid(r.roleid), member_id: find_roleid(m.role.roleid), @@ -315,6 +354,13 @@ def create! ) end end + clean_membership_cache + end + + def verify(role, member) + unless %w[user host group layer].include?(member.role.role_kind) and %w[group layer].include?(role.role_kind) + raise Exceptions::InvalidPolicyObject.new(role.id, message: "'#{member.role.role_kind}' cannot be a member of '#{role.role_kind}'") + end end end @@ -337,6 +383,7 @@ def create! end class Policy < Types::Base + include Secrets::RedisHandler def_delegators :@policy_object, :role, :resource, :body def create! @@ -344,6 +391,8 @@ def create! Types.wrap(self.resource, external_handler).create! Array(body).map(&:create!) + + clean_membership_cache end end @@ -354,29 +403,64 @@ class Deletion < Types::Base class Deny < Deletion def delete! - permission = ::Permission[role_id: policy_object.role.roleid, privilege: policy_object.privilege, resource_id: policy_object.resource.resourceid, policy_id: policy_id] - permission.destroy if permission + Array(policy_object.resource).each do |r| + Array(policy_object.privilege).each do |p| + Array(policy_object.role).each do |m| + permission = ::Permission[role_id: m.roleid, privilege: p, resource_id: r.resourceid, policy_id: policy_id] + permission.destroy if permission + end + end + end end end class Revoke < Deletion + include Secrets::RedisHandler def delete! - membership = ::RoleMembership[role_id: policy_object.role.roleid, member_id: policy_object.member.roleid, policy_id: policy_id] - membership.destroy if membership + Array(policy_object.role).each do |r| + Array(policy_object.member).each do |m| + membership = ::RoleMembership[role_id: r.roleid, member_id: m.roleid, policy_id: policy_id] + membership.destroy if membership + end + end + clean_membership_cache end end class Delete < Deletion + include Secrets::RedisHandler + include CurrentUser def delete! + if policy_object.record.respond_to?(:roleid) + delete_recursive!(policy_object.record.roleid) + end if policy_object.record.respond_to?(:resourceid) - resource = ::Resource[policy_object.record.resourceid] - resource.destroy if resource + delete_recursive!(policy_object.record.resourceid) end - if policy_object.record.respond_to?(:roleid) - role = ::Role[policy_object.record.roleid] - role.destroy if role + clean_membership_cache + end + + def delete_recursive!(record_id) + # First delete all resources and roles that are owned by this resource + ::Resource.where(owner_id: record_id).each do |resource| + delete_recursive!(resource.resource_id) end + + # Delete any resource or role that matches the record_id + resource = ::Resource[record_id] + if resource + resource.destroy + ## remove role (user or host) + delete_redis_user(resource.id) if resource.kind == 'user' || resource.kind == 'host' + ## remove secret + delete_redis_secret(resource.id) if resource.kind == 'variable' + ## remove resource_id for variable in show endpoint + delete_redis_resource(resource.id) if resource.kind == 'variable' + end + role = ::Role[record_id] + role.destroy if role end + end end end diff --git a/app/models/policy_version.rb b/app/models/policy_version.rb index 1c83aa61e8..1663f72073 100644 --- a/app/models/policy_version.rb +++ b/app/models/policy_version.rb @@ -96,7 +96,7 @@ def after_save def log_versions_to_expire expired_versions.all.each do |policy_version| - Rails.logger.debug( + Rails.logger.debug{ "Deleting policy version: #{policy_version.slice( :version, :resource_id, @@ -104,7 +104,7 @@ def log_versions_to_expire :created_at, :client_ip )}]" - ) + } end end diff --git a/app/models/pubsub/events/event_input.rb b/app/models/pubsub/events/event_input.rb new file mode 100644 index 0000000000..8a47d861bf --- /dev/null +++ b/app/models/pubsub/events/event_input.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +require 'singleton' +class EventInput + include Singleton + # Should return event_type and event_value + def get_event_input(operation, db_obj) + raise NotImplementedError + end + + # Generates event input based on specific implementation of `get_event_input` in derived class + # And creates event based on that input + def send_event(operation, db_obj) + if Rails.application.config.conjur_config.try(:conjur_pubsub_enabled) + event_type, event_value = get_event_input(operation, db_obj) + Event.create_event(event_type: event_type, event_value: event_value) + end + end + + protected + + def get_entity_type + raise NotImplementedError + end + + def get_event_type(operation) + ['conjur', get_entity_type, operation].join('.') + end +end diff --git a/app/models/pubsub/events/permissions_event_input.rb b/app/models/pubsub/events/permissions_event_input.rb new file mode 100644 index 0000000000..e9752ed767 --- /dev/null +++ b/app/models/pubsub/events/permissions_event_input.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class PermissionEventInput < EventInput + include ResourcesHandler + include RolesHandler + + CREATED = :create + DELETED = :delete + + INPUTS = { + CREATED => { event_op: 'created', data: Struct.new(:branch, :name, :permission, keyword_init: true) }.freeze, + DELETED => { event_op: 'deleted', data: Struct.new(:branch, :name, :permission, keyword_init: true) }.freeze + }.freeze + + def get_event_input(operation, db_obj) + input = INPUTS[operation] + branch, name, @resource_type = parse_resource_id(db_obj.resource_id, v2_syntax: true).values_at(:branch, :name, :type) + kind, id = parse_role_id(db_obj.role_id, v2_syntax: true).values_at(:type, :id) + + # This line must come after @resource_type is set + event_type = get_event_type(input[:event_op]) + + subject = { kind: kind, id: id } + permission = { subject: subject, privilege: db_obj.privilege } + + event_data = {} + event_data[:data] = input[:data].new(branch: branch, name: name, permission: permission) + event_data[:specversion] = "1.0" + + event_value = event_data.to_json + [event_type, event_value] + end + + protected + + def get_entity_type + "#{@resource_type}.permission" + end +end diff --git a/app/models/pubsub/events/secret_event_input.rb b/app/models/pubsub/events/secret_event_input.rb new file mode 100644 index 0000000000..a549f65adf --- /dev/null +++ b/app/models/pubsub/events/secret_event_input.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require './app/domain/resources/resources_handler' + +class SecretEventInput < EventInput + include ResourcesHandler + include RolesHandler + + CREATE = :create + DELETE = :delete + CHANGE = :change + + INPUTS = { + CREATE => {event_op: 'created', data: Struct.new( :branch, :name, :owner, keyword_init: true)}, + DELETE => {event_op: 'deleted', data: Struct.new( :branch, :name, keyword_init: true)}, + CHANGE => {event_op: 'value.changed', data: Struct.new( :branch, :name, :version, keyword_init: true)} + } + + def get_event_input(operation, db_obj) + input = INPUTS[operation] + event_type = get_event_type(input[:event_op]) + + branch, name = parse_resource_id(db_obj.resource_id).values_at(:branch, :name) + data = input[:data] + args = { branch: branch, + name: name } + + args[:version] = db_obj.version.to_i if data.members.include?(:version) + + if data.members.include?(:owner) + kind, id = parse_role_id(db_obj[:owner_id], v2_syntax: true).values_at(:type, :id) + args[:owner] = { kind: kind, id: id } + end + dict = {} + dict[:data] = data.new(args).to_h + dict[:specversion] = "1.0" + event_value = dict.to_json + + [event_type, event_value] + end + + protected + + def get_entity_type + 'secret' + end +end + diff --git a/app/models/resource.rb b/app/models/resource.rb index f6b403266c..16e8da6c20 100644 --- a/app/models/resource.rb +++ b/app/models/resource.rb @@ -23,6 +23,20 @@ def identifier id.split(":", 3)[2] end + def from_hash!(resource_hash) + self.resource_id = resource_hash["id"] + self.owner_id = resource_hash['owner'] + self.policy_id = resource_hash['policy'] + end + + def to_hash! + { + "id" => id, + "owner" => owner_id, + "policy" => policy_id + } + end + def as_json options = {} super(options).tap do |response| response["id"] = response.delete("resource_id") @@ -88,13 +102,22 @@ def extended_associations # @param offset [Numeric] - an offset into the list of returned results # @param limit [Numeric] - a maximum number of results to return # @param search [String] - a search term in the resource id - def search account: nil, kind: nil, owner: nil, offset: nil, limit: nil, search: nil + def search account: nil, kind: nil, owner: nil, offset: nil, limit: nil, search: nil, exclude:nil scope = self # Filter by kind and account. scope = scope.where(Sequel.lit("account(resource_id) = ?", account)) if account scope = scope.where(Sequel.lit("kind(resource_id) = ?", kind)) if kind + # Filter by exclude + if exclude + exclude = exclude.split(',') + exclude.each do |item| + # scope is exclude when the resource id is like the item + scope = scope.exclude(Sequel.like(:resource_id, "%#{item}%")) + end + end + # Filter by owner if owner owners = Resource.from(::Sequel.function(:all_roles, owner.id)).select(:role_id) @@ -164,22 +187,6 @@ def permit privilege, role, options = {} add_permission(options) end - # Truncate secrets beyond the configured limit. - def enforce_secrets_version_limit limit = secrets_version_limit - # The Sequel-foo for this escapes me. - Sequel::Model.db[<<-SQL, resource_id, limit, resource_id].delete - WITH - "ordered_secrets" AS - (SELECT * FROM "secrets" WHERE ("resource_id" = ?) ORDER BY "version" DESC LIMIT ?), - "delete_secrets" AS - (SELECT * FROM "secrets" LEFT JOIN "ordered_secrets" USING ("resource_id", "version") WHERE (("ordered_secrets"."resource_id" IS NULL) AND ("resource_id" = ?))) - DELETE FROM "secrets" - USING "delete_secrets" - WHERE "secrets"."resource_id" = "delete_secrets"."resource_id" AND - "secrets"."version" = "delete_secrets"."version" - SQL - end - def last_secret secrets_dataset.order(Sequel.desc(:version)).first end diff --git a/app/models/role.rb b/app/models/role.rb index 6785216f27..4a66bc9e64 100644 --- a/app/models/role.rb +++ b/app/models/role.rb @@ -3,6 +3,7 @@ class Role < Sequel::Model extend Forwardable include HasId + include Authentication::OptionalApiKey unrestrict_primary_key @@ -19,6 +20,9 @@ class Role < Sequel::Model extend: MembershipSearch, search_key: :role_id ) + + one_to_many :annotations, :key => :resource_id, :class => :Annotation + one_to_one :credentials, reciprocal: :role alias id role_id @@ -33,6 +37,14 @@ def as_json options = {} end end + def to_hash! + { + "role_id" => role_id + } + end + def from_hash! role_hash + self.role_id = role_hash["role_id"] + end class << self def that_can(permission, resource) Role.from( @@ -63,6 +75,21 @@ def username_from_roleid(roleid) [kind, id].join('/') end + + def roles_with_annotations(roles) + #this subquery aggregates all annotation of a role to an array of key value pairs, key- annotation name, value- annotation value + subquery = Sequel.function(:jsonb_agg, + Sequel.case( + { Sequel.~(:name => nil) => + Sequel.function(:jsonb_build_object, + "name", :name, + "value", :value + )}, + nil + )).as(:annotations) + roles.left_join(:annotations, resource_id: :role_id).group_by(:role_id).select_group(:role_id) + .select_append(subquery) + end end dataset_module do @@ -87,7 +114,11 @@ def password=(password) def valid_origin?(ip_addr) ip = IPAddr.new(ip_addr) restricted_to.blank? || restricted_to.any? do |cidr| + # Ran into an issue checking this via tests... + # Not sure if this is actually a bug... cidr.include?(ip) + rescue + cidr.include?(ip.to_s) end end @@ -103,6 +134,7 @@ def restricted_to=(restricted_to) end def api_key + unless self.credentials _, kind, id = self.id.split(":", 3) allowed_kind = %w[user host deputy].member?(kind) @@ -114,6 +146,11 @@ def api_key self.credentials.api_key end + def api_key_expected? + self.id == 'admin' || self.kind == 'user' || + self.annotations.any? { |a| api_key_annotation_true?(a) } + end + def login self.class.username_from_roleid(role_id) end @@ -160,6 +197,14 @@ def allowed_to?(privilege, resource) ).first[:is_role_allowed_to] end + # Checks if a user has update permissions on a given policy and all of its + # descendants. + def policy_permissions?(policy, permission) + Role.from( + Sequel.function(:policy_permissions, id, permission, policy.id) + ).first[:policy_permissions] + end + def all_roles Role.from(Sequel.function(:all_roles, id)) end @@ -170,11 +215,22 @@ def ancestor_of?(role) ).first[:is_role_ancestor_of] end - def graph - Role.from(Sequel.function(:role_graph, id)) + # Returns an array of [parent, child] pairs used to construct the role graph + # for the given user. Only roles that are visible (readable) to the user are + # contained within this response. + def graph(user_id) + Role.from(Sequel.as(Sequel.function(:role_graph, id), :t1)) + .join(Sequel.as( + Sequel.function(:visible_resources, user_id), + :t2 + ), + (Sequel[{ resource_id: :parent }] )) + .select(:parent, :child) + .distinct(:parent, :child) .order(:parent, :child) .all .map(&:values) + end private diff --git a/app/models/secret.rb b/app/models/secret.rb index f44438013f..d8458ee461 100644 --- a/app/models/secret.rb +++ b/app/models/secret.rb @@ -99,4 +99,20 @@ def validate raise Sequel::ValidationFailed, "Value is not present" unless @values[:value] end + + # Truncate secrets beyond the configured limit. + def enforce_secrets_version_limit limit = secrets_version_limit + # The Sequel-foo for this escapes me. + Sequel::Model.db[<<-SQL, resource_id, limit, resource_id].delete + WITH + "ordered_secrets" AS + (SELECT * FROM "secrets" WHERE ("resource_id" = ?) ORDER BY "version" DESC LIMIT ?), + "delete_secrets" AS + (SELECT * FROM "secrets" LEFT JOIN "ordered_secrets" USING ("resource_id", "version") WHERE (("ordered_secrets"."resource_id" IS NULL) AND ("resource_id" = ?))) + DELETE FROM "secrets" + USING "delete_secrets" + WHERE "secrets"."resource_id" = "delete_secrets"."resource_id" AND + "secrets"."version" = "delete_secrets"."version" + SQL + end end diff --git a/app/presenters/policy_factories/error.rb b/app/presenters/policy_factories/error.rb new file mode 100644 index 0000000000..ed84a45ca0 --- /dev/null +++ b/app/presenters/policy_factories/error.rb @@ -0,0 +1,28 @@ +module Presenter + module PolicyFactories + # Returns a Hash representation of an Failure Response to be used by the controller + class Error + # Response is always a FailureResponse + def initialize(response:, response_codes: HTTP::Response::Status::SYMBOL_CODES) + @response = response + @response_codes = response_codes + end + + def present + { + code: @response_codes[@response.status] + }.tap do |rtn| + rtn[:error] = format_error_message(@response.message) + end + end + + private + + def format_error_message(message) + return message if message.is_a?(Array) || message.is_a?(Hash) + + { message: message.to_s } + end + end + end +end diff --git a/app/presenters/policy_factories/index.rb b/app/presenters/policy_factories/index.rb new file mode 100644 index 0000000000..36fea65755 --- /dev/null +++ b/app/presenters/policy_factories/index.rb @@ -0,0 +1,35 @@ +module Presenter + module PolicyFactories + # returns a Hash representation to be used by the controller + class Index + def initialize(factories:) + @factories = factories + end + + def present + {}.tap do |rtn| + @factories + .group_by(&:classification) + .sort_by {|classification, _| classification } + .map do |classification, factories| + rtn[classification] = factories + .map { |factory| factory_to_hash(factory) } + .sort { |x, y| x[:name] <=> y[:name] } + end + end + end + + private + + def factory_to_hash(factory) + { + name: factory.name, + namespace: factory.classification, + 'full-name': "#{factory.classification}/#{factory.name}", + 'current-version': factory.version, + description: factory.description || '' + } + end + end + end +end diff --git a/app/presenters/policy_factories/show.rb b/app/presenters/policy_factories/show.rb new file mode 100644 index 0000000000..1bfdcd2f00 --- /dev/null +++ b/app/presenters/policy_factories/show.rb @@ -0,0 +1,20 @@ +module Presenter + module PolicyFactories + # returns a hash representation to be used by the controller + class Show + def initialize(factory:) + @factory = factory + end + + def present + { + title: @factory.schema['title'], + version: @factory.version, + description: @factory.schema['description'], + properties: @factory.schema['properties'], + required: @factory.schema['required'] + } + end + end + end +end diff --git a/app/views/status/index.html.erb b/app/views/status/index.html.erb index a60957aae3..d7084d7b4f 100644 --- a/app/views/status/index.html.erb +++ b/app/views/status/index.html.erb @@ -24,7 +24,7 @@

Status

Your Conjur server is running!

- +

Security Check:

Does your browser show a green lock icon on the left side of the address bar?

@@ -58,6 +58,7 @@
Details:
Version <%= ENV["CONJUR_VERSION_DISPLAY"] %>
API Version "><%= ENV["API_VERSION"] %> +
FIPS mode <%= ENV["FIPS_MODE_STATUS"] %>
More Info:
    @@ -70,7 +71,7 @@
- +