diff --git a/.circleci/build-and-test/jobs.yml b/.circleci/build-and-test/jobs.yml index 4e32831f8..5e58a99ae 100644 --- a/.circleci/build-and-test/jobs.yml +++ b/.circleci/build-and-test/jobs.yml @@ -4,7 +4,7 @@ steps: - checkout - docker-compose-check - - docker-compose-up-backend + - docker-compose-up-with-elastic-backend - run: name: Run Unit Tests And Create Code Coverage Report command: | @@ -47,7 +47,7 @@ steps: - checkout - docker-compose-check - - docker-compose-up-backend + - docker-compose-up-with-elastic-backend - docker-compose-up-frontend - install-nodejs-machine - disable-npm-audit @@ -61,7 +61,7 @@ wait-for-it --service http://web:8080 --timeout 180 -- echo \"Django is ready\"" - run: name: apply the migrations - command: cd tdrs-backend; docker-compose exec web bash -c "python manage.py makemigrations; python manage.py migrate" + command: cd tdrs-backend; docker-compose exec web bash -c "python manage.py makemigrations; python manage.py migrate" - run: name: Remove existing cypress test users command: cd tdrs-backend; docker-compose exec web python manage.py delete_cypress_users -usernames new-cypress@teamraft.com cypress-admin@teamraft.com diff --git a/.circleci/util/commands.yml b/.circleci/util/commands.yml index ebbdfb7e1..09d175b69 100644 --- a/.circleci/util/commands.yml +++ b/.circleci/util/commands.yml @@ -11,6 +11,12 @@ name: Build and spin-up Django API service command: cd tdrs-backend; docker network create external-net; docker-compose up -d --build + docker-compose-up-with-elastic-backend: + steps: + - run: + name: Build and spin-up Django API service + command: cd tdrs-backend; docker network create external-net; docker-compose --profile elastic_setup up -d --build + cf-check: steps: - run: diff --git a/tdrs-backend/docker-compose.yml b/tdrs-backend/docker-compose.yml index a6624688b..6a09c3944 100644 --- a/tdrs-backend/docker-compose.yml +++ b/tdrs-backend/docker-compose.yml @@ -50,7 +50,7 @@ services: ports: - 5601:5601 environment: - - xpack.security.encryptionKey="something_at_least_32_characters" + - xpack.security.encryptionKey=${KIBANA_ENCRYPTION_KEY:-something_at_least_32_characters} - xpack.security.session.idleTimeout="1h" - xpack.security.session.lifespan="30d" volumes: @@ -58,12 +58,42 @@ services: depends_on: - elastic + # This task only needs to be performed once, during the *initial* startup of + # the stack. Any subsequent run will reset the passwords of existing users to + # the values defined inside the '.env' file, and the built-in roles to their + # default permissions. + # + # By default, it is excluded from the services started by 'docker compose up' + # due to the non-default profile it belongs to. To run it, either provide the + # '--profile=elastic_setup' CLI flag to Compose commands, or "up" the service by name + # such as 'docker compose up elastic_setup'. + elastic_setup: + profiles: + - elastic_setup + build: + context: elastic_setup/ + args: + ELASTIC_VERSION: "7.17.6" + init: true + environment: + ELASTIC_PASSWORD: ${ELASTIC_PASSWORD:-changeme} + KIBANA_SYSTEM_PASSWORD: ${KIBANA_SYSTEM_PASSWORD:-changeme} + OFA_ADMIN_PASSWORD: ${OFA_ADMIN_PASSWORD:-changeme} + ELASTICSEARCH_HOST: ${ELASTICSEARCH_HOST:-elastic} + depends_on: + - elastic + elastic: image: elasticsearch:7.17.6 environment: - discovery.type=single-node - logger.discovery.level=debug - - xpack.security.enabled=false + - xpack.security.enabled=true + - xpack.security.authc.anonymous.username="ofa_admin" + - xpack.security.authc.anonymous.roles="ofa_admin" + - xpack.security.authc.anonymous.authz_exception=true + - ELASTIC_PASSWORD=${ELASTIC_PASSWORD:-changeme} + - KIBANA_SYSTEM_PASSWORD=${KIBANA_SYSTEM_PASSWORD:-changeme} ports: - 9200:9200 - 9300:9300 @@ -101,6 +131,7 @@ services: - CYPRESS_TOKEN - DJANGO_DEBUG - SENDGRID_API_KEY + - BYPASS_KIBANA_AUTH volumes: - .:/tdpapp image: tdp diff --git a/tdrs-backend/elastic_setup/Dockerfile b/tdrs-backend/elastic_setup/Dockerfile new file mode 100644 index 000000000..32e6429f6 --- /dev/null +++ b/tdrs-backend/elastic_setup/Dockerfile @@ -0,0 +1,10 @@ +ARG ELASTIC_VERSION + +FROM docker.elastic.co/elasticsearch/elasticsearch:${ELASTIC_VERSION} + +COPY . / + +RUN ["chmod", "+x", "/entrypoint.sh"] +RUN ["chmod", "+x", "/util.sh"] + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/tdrs-backend/elastic_setup/entrypoint.sh b/tdrs-backend/elastic_setup/entrypoint.sh new file mode 100644 index 000000000..6073b0540 --- /dev/null +++ b/tdrs-backend/elastic_setup/entrypoint.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash + +set -eu +set -o pipefail + +source "${BASH_SOURCE[0]%/*}"/util.sh + + +# -------------------------------------------------------- +# Users declarations + +declare -A users_passwords +users_passwords=( + [kibana_system]="${KIBANA_SYSTEM_PASSWORD:-}" + [ofa_admin]="${OFA_ADMIN_PASSWORD:-}" +) + +declare -A users_roles +users_roles=( + [kibana_system]='kibana_system' + [ofa_admin]='kibana_admin' +) + +# -------------------------------------------------------- +# Roles declarations for custom roles + +declare -A roles_files +roles_files=( + +) + +# -------------------------------------------------------- + + +log 'Waiting for availability of Elasticsearch. This can take several minutes.' + +declare -i exit_code=0 +wait_for_elasticsearch || exit_code=$? + +if ((exit_code)); then + case $exit_code in + 6) + suberr 'Could not resolve host. Is Elasticsearch running?' + ;; + 7) + suberr 'Failed to connect to host. Is Elasticsearch healthy?' + ;; + 28) + suberr 'Timeout connecting to host. Is Elasticsearch healthy?' + ;; + *) + suberr "Connection to Elasticsearch failed. Exit code: ${exit_code}" + ;; + esac + + exit $exit_code +fi + +sublog 'Elasticsearch is running' + +log 'Waiting for initialization of built-in users' + +wait_for_builtin_users || exit_code=$? + +if ((exit_code)); then + suberr 'Timed out waiting for condition' + exit $exit_code +fi + +sublog 'Built-in users were initialized' + +for role in "${!roles_files[@]}"; do + log "Role '$role'" + + declare body_file + body_file="${BASH_SOURCE[0]%/*}/roles/${roles_files[$role]:-}" + if [[ ! -f "${body_file:-}" ]]; then + sublog "No role body found at '${body_file}', skipping" + continue + fi + + sublog 'Creating/updating' + ensure_role "$role" "$(<"${body_file}")" +done + +for user in "${!users_passwords[@]}"; do + log "User '$user'" + if [[ -z "${users_passwords[$user]:-}" ]]; then + sublog 'No password defined, skipping' + continue + fi + + declare -i user_exists=0 + user_exists="$(check_user_exists "$user")" + + if ((user_exists)); then + sublog 'User exists, setting password' + set_user_password "$user" "${users_passwords[$user]}" + else + if [[ -z "${users_roles[$user]:-}" ]]; then + suberr ' No role defined, skipping creation' + continue + fi + + sublog 'User does not exist, creating' + create_user "$user" "${users_passwords[$user]}" "${users_roles[$user]}" + fi +done + +log "Elastic setup completed. Exiting with code: $?" diff --git a/tdrs-backend/elastic_setup/util.sh b/tdrs-backend/elastic_setup/util.sh new file mode 100644 index 000000000..045110249 --- /dev/null +++ b/tdrs-backend/elastic_setup/util.sh @@ -0,0 +1,240 @@ +#!/usr/bin/env bash + +# Log a message. +function log { + echo "[+] $1" +} + +# Log a message at a sub-level. +function sublog { + echo " ⠿ $1" +} + +# Log an error. +function err { + echo "[x] $1" >&2 +} + +# Log an error at a sub-level. +function suberr { + echo " ⠍ $1" >&2 +} + +# Poll the 'elasticsearch' service until it responds with HTTP code 200. +function wait_for_elasticsearch { + local elasticsearch_host="${ELASTICSEARCH_HOST:-elastic}" + + local -a args=( '-s' '-D-' '-m15' '-w' '%{http_code}' "http://${elasticsearch_host}:9200/" ) + + if [[ -n "${ELASTIC_PASSWORD:-}" ]]; then + args+=( '-u' "elastic:${ELASTIC_PASSWORD}" ) + fi + + local -i result=1 + local output + + # retry for max 300s (60*5s) + for _ in $(seq 1 60); do + local -i exit_code=0 + output="$(curl "${args[@]}")" || exit_code=$? + + if ((exit_code)); then + result=$exit_code + fi + + if [[ "${output: -3}" -eq 200 ]]; then + result=0 + break + fi + + sleep 5 + done + + if ((result)) && [[ "${output: -3}" -ne 000 ]]; then + echo -e "\n${output::-3}" + fi + + return $result +} + +# Poll the Elasticsearch users API until it returns users. +function wait_for_builtin_users { + local elasticsearch_host="${ELASTICSEARCH_HOST:-elastic}" + + local -a args=( '-s' '-D-' '-m15' "http://${elasticsearch_host}:9200/_security/user?pretty" ) + + if [[ -n "${ELASTIC_PASSWORD:-}" ]]; then + args+=( '-u' "elastic:${ELASTIC_PASSWORD}" ) + fi + + local -i result=1 + + local line + local -i exit_code + local -i num_users + + # retry for max 30s (30*1s) + for _ in $(seq 1 30); do + num_users=0 + + # read exits with a non-zero code if the last read input doesn't end + # with a newline character. The printf without newline that follows the + # curl command ensures that the final input not only contains curl's + # exit code, but causes read to fail so we can capture the return value. + # Ref. https://unix.stackexchange.com/a/176703/152409 + while IFS= read -r line || ! exit_code="$line"; do + if [[ "$line" =~ _reserved.+true ]]; then + (( num_users++ )) + fi + done < <(curl "${args[@]}"; printf '%s' "$?") + + if ((exit_code)); then + result=$exit_code + fi + + # we expect more than just the 'elastic' user in the result + if (( num_users > 1 )); then + result=0 + break + fi + + sleep 1 + done + + return $result +} + +# Verify that the given Elasticsearch user exists. +function check_user_exists { + local username=$1 + + local elasticsearch_host="${ELASTICSEARCH_HOST:-elastic}" + + local -a args=( '-s' '-D-' '-m15' '-w' '%{http_code}' + "http://${elasticsearch_host}:9200/_security/user/${username}" + ) + + if [[ -n "${ELASTIC_PASSWORD:-}" ]]; then + args+=( '-u' "elastic:${ELASTIC_PASSWORD}" ) + fi + + local -i result=1 + local -i exists=0 + local output + + output="$(curl "${args[@]}")" + if [[ "${output: -3}" -eq 200 || "${output: -3}" -eq 404 ]]; then + result=0 + fi + if [[ "${output: -3}" -eq 200 ]]; then + exists=1 + fi + + if ((result)); then + echo -e "\n${output::-3}" + else + echo "$exists" + fi + + return $result +} + +# Set password of a given Elasticsearch user. +function set_user_password { + local username=$1 + local password=$2 + + local elasticsearch_host="${ELASTICSEARCH_HOST:-elastic}" + + local -a args=( '-s' '-D-' '-m15' '-w' '%{http_code}' + "http://${elasticsearch_host}:9200/_security/user/${username}/_password" + '-X' 'POST' + '-H' 'Content-Type: application/json' + '-d' "{\"password\" : \"${password}\"}" + ) + + if [[ -n "${ELASTIC_PASSWORD:-}" ]]; then + args+=( '-u' "elastic:${ELASTIC_PASSWORD}" ) + fi + + local -i result=1 + local output + + output="$(curl "${args[@]}")" + if [[ "${output: -3}" -eq 200 ]]; then + result=0 + fi + + if ((result)); then + echo -e "\n${output::-3}\n" + fi + + return $result +} + +# Create the given Elasticsearch user. +function create_user { + local username=$1 + local password=$2 + local role=$3 + + local elasticsearch_host="${ELASTICSEARCH_HOST:-elastic}" + + local -a args=( '-s' '-D-' '-m15' '-w' '%{http_code}' + "http://${elasticsearch_host}:9200/_security/user/${username}" + '-X' 'POST' + '-H' 'Content-Type: application/json' + '-d' "{\"password\":\"${password}\",\"roles\":[\"${role}\"]}" + ) + + if [[ -n "${ELASTIC_PASSWORD:-}" ]]; then + args+=( '-u' "elastic:${ELASTIC_PASSWORD}" ) + fi + + local -i result=1 + local output + + output="$(curl "${args[@]}")" + if [[ "${output: -3}" -eq 200 ]]; then + result=0 + fi + + if ((result)); then + echo -e "\n${output::-3}\n" + fi + + return $result +} + +# Ensure that the given Elasticsearch role is up-to-date, create it if required. +function ensure_role { + local name=$1 + local body=$2 + + local elasticsearch_host="${ELASTICSEARCH_HOST:-elastic}" + + local -a args=( '-s' '-D-' '-m15' '-w' '%{http_code}' + "http://${elasticsearch_host}:9200/_security/role/${name}" + '-X' 'POST' + '-H' 'Content-Type: application/json' + '-d' "$body" + ) + + if [[ -n "${ELASTIC_PASSWORD:-}" ]]; then + args+=( '-u' "elastic:${ELASTIC_PASSWORD}" ) + fi + + local -i result=1 + local output + + output="$(curl "${args[@]}")" + if [[ "${output: -3}" -eq 200 ]]; then + result=0 + fi + + if ((result)); then + echo -e "\n${output::-3}\n" + fi + + return $result +} \ No newline at end of file diff --git a/tdrs-backend/kibana.yml b/tdrs-backend/kibana.yml index dad4335d0..e98d2438d 100644 --- a/tdrs-backend/kibana.yml +++ b/tdrs-backend/kibana.yml @@ -1,2 +1,12 @@ elasticsearch.hosts: ["http://elastic:9200"] server.host: kibana +elasticsearch.username: kibana_system +elasticsearch.password: changeme +xpack.security.authc.providers: + anonymous.anonymous1: + order: 0 + description: "OFA Admin Login" + hint: "" + credentials: + username: "ofa_admin" + password: "changeme" diff --git a/tdrs-backend/tdpservice/settings/common.py b/tdrs-backend/tdpservice/settings/common.py index dc4e4c51e..108586c80 100644 --- a/tdrs-backend/tdpservice/settings/common.py +++ b/tdrs-backend/tdpservice/settings/common.py @@ -465,11 +465,14 @@ class Common(Configuration): } } - # Elastic + # Elastic/Kibana ELASTICSEARCH_DSL = { 'default': { 'hosts': os.getenv('ELASTIC_HOST', 'elastic:9200'), + 'http_auth': ('elastic', os.getenv('ELASTIC_PASSWORD', 'changeme')) }, } + KIBANA_BASE_URL = os.getenv('KIBANA_BASE_URL', 'http://localhost:5601') + BYPASS_KIBANA_AUTH = strtobool(os.getenv("BYPASS_KIBANA_AUTH", "no")) CYPRESS_TOKEN = os.getenv('CYPRESS_TOKEN', None) diff --git a/tdrs-backend/tdpservice/urls.py b/tdrs-backend/tdpservice/urls.py index 26858b356..368314c92 100755 --- a/tdrs-backend/tdpservice/urls.py +++ b/tdrs-backend/tdpservice/urls.py @@ -11,7 +11,7 @@ from rest_framework.permissions import AllowAny -from .users.api.authorization_check import AuthorizationCheck +from .users.api.authorization_check import AuthorizationCheck, KibanaAuthorizationCheck from .users.api.login import TokenAuthorizationLoginDotGov, TokenAuthorizationAMS from .users.api.login import CypressLoginDotGovAuthenticationOverride from .users.api.login_redirect_oidc import LoginRedirectAMS, LoginRedirectLoginDotGov @@ -52,6 +52,7 @@ urlpatterns = [ path("v1/", include(urlpatterns)), path("admin/", admin.site.urls, name="admin"), + path("kibana/", KibanaAuthorizationCheck.as_view(), name="kibana-authorization-check"), ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) # TODO: Supply `terms_of_service` argument in OpenAPI Info once implemented diff --git a/tdrs-backend/tdpservice/users/api/authorization_check.py b/tdrs-backend/tdpservice/users/api/authorization_check.py index 3ac867be0..76afeecb1 100644 --- a/tdrs-backend/tdpservice/users/api/authorization_check.py +++ b/tdrs-backend/tdpservice/users/api/authorization_check.py @@ -4,10 +4,12 @@ from django.contrib.auth import logout from django.middleware import csrf from django.utils import timezone -from rest_framework.permissions import AllowAny +from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView from ..serializers import UserProfileSerializer +from django.http import HttpResponseRedirect +from django.conf import settings logger = logging.getLogger(__name__) @@ -49,3 +51,21 @@ def get(self, request, *args, **kwargs): else: logger.info("Auth check FAIL for user on %s", timezone.now()) return Response({"authenticated": False}) + +class KibanaAuthorizationCheck(APIView): + """Check if user is authorized to view Kibana.""" + + query_string = False + pattern_name = "kibana-authorization-check" + permission_classes = [IsAuthenticated] + + def get(self, request, *args, **kwargs): + """Handle get request and verify user is authorized to access kibana.""" + user = request.user + + user_in_valid_group = user.is_ofa_sys_admin + + if (user.hhs_id is not None and user_in_valid_group) or settings.BYPASS_KIBANA_AUTH: + return HttpResponseRedirect(settings.KIBANA_BASE_URL) + else: + return HttpResponseRedirect(settings.FRONTEND_BASE_URL) diff --git a/tdrs-backend/tdpservice/users/models.py b/tdrs-backend/tdpservice/users/models.py index d0a9c924d..2dd8dd3c1 100644 --- a/tdrs-backend/tdpservice/users/models.py +++ b/tdrs-backend/tdpservice/users/models.py @@ -180,6 +180,11 @@ def is_ocio_staff(self) -> bool: """Return whether or not the user is in the ACF OCIO Group.""" return self.is_in_group("ACF OCIO") + @property + def is_ofa_sys_admin(self) -> bool: + """Return whether or not the user is in the OFA System Admin Group.""" + return self.is_in_group("OFA System Admin") + @property def is_deactivated(self): """Check if the user's account status has been set to 'Deactivated'.""" diff --git a/tdrs-frontend/nginx/local/locations.conf b/tdrs-frontend/nginx/local/locations.conf index 2fc38d3ad..154cda557 100644 --- a/tdrs-frontend/nginx/local/locations.conf +++ b/tdrs-frontend/nginx/local/locations.conf @@ -4,7 +4,7 @@ location = /nginx_status { deny all; } -location ~ ^/(v1|admin|static/admin|swagger|redocs) { +location ~ ^/(v1|admin|static/admin|swagger|redocs|kibana) { limit_req zone=limitreqsbyaddr delay=5; proxy_pass http://${BACK_END}:8080$request_uri; proxy_set_header Host $host:3000; diff --git a/tdrs-frontend/src/components/Header/Header.jsx b/tdrs-frontend/src/components/Header/Header.jsx index 2f6c5335b..201cd55bf 100644 --- a/tdrs-frontend/src/components/Header/Header.jsx +++ b/tdrs-frontend/src/components/Header/Header.jsx @@ -7,6 +7,7 @@ import { accountStatusIsApproved, accountIsInReview, accountCanViewAdmin, + accountCanViewKibana, } from '../../selectors/auth' import NavItem from '../NavItem/NavItem' @@ -29,6 +30,7 @@ function Header() { const userAccessRequestPending = useSelector(accountIsInReview) const userAccessRequestApproved = useSelector(accountStatusIsApproved) const userIsAdmin = useSelector(accountCanViewAdmin) + const userIsSysAdmin = useSelector(accountCanViewKibana) const menuRef = useRef() @@ -137,6 +139,13 @@ function Header() { href={`${process.env.REACT_APP_BACKEND_HOST}/admin/`} /> )} + {userIsSysAdmin && ( + + )} )} diff --git a/tdrs-frontend/src/components/SiteMap/SiteMap.jsx b/tdrs-frontend/src/components/SiteMap/SiteMap.jsx index 1df805e7d..5ad40fc4e 100644 --- a/tdrs-frontend/src/components/SiteMap/SiteMap.jsx +++ b/tdrs-frontend/src/components/SiteMap/SiteMap.jsx @@ -3,11 +3,13 @@ import { useSelector } from 'react-redux' import { accountStatusIsApproved, accountCanViewAdmin, + accountCanViewKibana, } from '../../selectors/auth' const SiteMap = ({ user }) => { const userIsApproved = useSelector(accountStatusIsApproved) const userIsAdmin = useSelector(accountCanViewAdmin) + const userIsSysAdmin = useSelector(accountCanViewKibana) return (
@@ -31,6 +33,13 @@ const SiteMap = ({ user }) => { link={`${process.env.REACT_APP_BACKEND_HOST}/admin/`} /> )} + + {userIsSysAdmin && ( + + )}
) } diff --git a/tdrs-frontend/src/selectors/auth.js b/tdrs-frontend/src/selectors/auth.js index b79d2b6b1..ab962e275 100644 --- a/tdrs-frontend/src/selectors/auth.js +++ b/tdrs-frontend/src/selectors/auth.js @@ -59,3 +59,7 @@ export const accountCanViewAdmin = (state) => ['Developer', 'OFA System Admin', 'ACF OCIO', 'OFA Admin'].includes( selectPrimaryUserRole(state)?.name ) + +export const accountCanViewKibana = (state) => + accountStatusIsApproved(state) && + ['Developer', 'OFA System Admin'].includes(selectPrimaryUserRole(state)?.name)