Repository: raft-tech/TANF-app
Issue category: Security Vulnerability

Spike: Secure OFA staff access to Kibana
**Description:**
As a front-runner to #1350, I wanted to explore the authentication and permission systems for ES/Kibana, TDP authorization of roles for those services, Cloud networking configurations, system hardening guidelines, etc.

I **cannot** understress the importance of security around the data within Elasticsearch that this ticket stalwarts against. This might be the right time to undertake #753.

**Acceptance Criteria:** 
- [ ] Dev-ready requirements for #1350 and potential follow-on tickets
- [ ] Recommended cloudfoundry security guidelines and configurations
- [ ] TDP user logic and flows for authorization
- [ ] Kibana/ES permissions and configurations guidelines/recommendations
- [ ] Application security hardening both for Kibana/ES and TDP
- [ ] Investigate/automate security scans around our data pool
- [ ] Testing Checklist has been run and all tests pass
- [ ] README is updated, if necessary

**Tasks:**
- [ ] Research according to ACs
- [ ] Have write-ups and recommendations for those areas of concern

**Notes:**

**Supporting Documentation:**


**Open Questions:**



Functions to edit:
tdrs-backend/tdpservice/settings/common.py:Common
tdrs-backend/tdpservice/users/models.py:User
Functions added: tdrs-backend/tdpservice/users/models.py:User.is_ofa_sys_admin

Patch:
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 && (
+                    <NavItem
+                      pathname={pathname}
+                      tabTitle="Kibana"
+                      href={`${process.env.REACT_APP_BACKEND_HOST}/kibana/`}
+                    />
+                  )}
                 </>
               )}
             </ul>
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 (
     <div className="margin-top-5">
@@ -31,6 +33,13 @@ const SiteMap = ({ user }) => {
           link={`${process.env.REACT_APP_BACKEND_HOST}/admin/`}
         />
       )}
+
+      {userIsSysAdmin && (
+        <SiteMap.Link
+          text="Kibana"
+          link={`${process.env.REACT_APP_BACKEND_HOST}/kibana/`}
+        />
+      )}
     </div>
   )
 }
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)

Test patch: 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