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 && (
+