diff --git a/.github/workflows/publish-openshift-images.yml b/.github/workflows/publish-openshift-images.yml new file mode 100644 index 00000000..67531e77 --- /dev/null +++ b/.github/workflows/publish-openshift-images.yml @@ -0,0 +1,71 @@ +name: Publish Openshift Images + +on: + workflow_dispatch: + push: + paths: + - "docker/openshift/**" + - "openshift/**" + +env: + GITHUB_IMAGE_REPO: ghcr.io/bcgov/jasper + SRC_PATH: ../../docker/openshift + +permissions: + id-token: write + packages: write + +jobs: + deploy: + runs-on: ubuntu-latest + name: Deploy Images + strategy: + matrix: + dockerfile-image: + - Dockerfile=./docker/openshift/Dockerfile.sync-secrets,image=sync-secrets + - Dockerfile=./docker/openshift/Dockerfile.update-aws-creds,image=update-aws-creds + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Log in to the GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + driver: docker + + - name: Parse Dockerfile and Image Name + id: parse + run: | + echo "Dockerfile: ${{ matrix.dockerfile-image }}" + DOCKERFILE=$(echo "${{ matrix.dockerfile-image }}" | cut -d',' -f1 | cut -d'=' -f2) + IMAGE=$(echo "${{ matrix.dockerfile-image }}" | cut -d',' -f2 | cut -d'=' -f2) + echo "DOCKERFILE=$DOCKERFILE" >> $GITHUB_ENV + echo "IMAGE=$IMAGE" >> $GITHUB_ENV + + - name: Setup Image Metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: | + ${{ env.GITHUB_IMAGE_REPO }}/${{ env.IMAGE }} + tags: | + type=raw,value=latest + + - name: Build and Push Image to ghcr.io + uses: docker/build-push-action@v5 + with: + push: true + context: . + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + file: ${{ env.DOCKERFILE }} + build-args: | + SRC=${{ env.SRC_PATH }} \ No newline at end of file diff --git a/.github/workflows/sync-secrets.yml b/.github/workflows/sync-secrets.yml deleted file mode 100644 index 728cf478..00000000 --- a/.github/workflows/sync-secrets.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: Sync Secrets - -on: - workflow_dispatch: - -jobs: - sync: - runs-on: ubuntu-latest - name: Sync Secrets - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Openshift Cluster Login - uses: redhat-actions/oc-login@v1.3 - with: - openshift_server_url: ${{ vars.OPENSHIFT_SERVER_URL }} - openshift_token: ${{ secrets.EMERALD_DEV_SA_PIPELINE_TOKEN }} - insecure_skip_tls_verify: true - namespace: ${{ vars.OPENSHIFT_NAMESPACE }}-${{ vars.ENVIRONMENT }} - - - name: Determine MOUNT name - id: mount - run: | - if [[ "${{ vars.ENVIRONMENT }}" != "prod" ]]; then - echo "MOUNT=${{ vars.OPENSHIFT_NAMESPACE }}-nonprod" >> $GITHUB_ENV - else - echo "MOUNT=${{ vars.OPENSHIFT_NAMESPACE }}-prod" >> $GITHUB_ENV - shell: bash - - - name: Import secrets - id: vault - uses: hashicorp/vault-action@v3 - with: - url: ${{ vars.VAULT_URL }} - token: ${{ secrets.VAULT_TOKEN }} - exportEnv: "false" - namespace: platform-services - secrets: | - $MOUNT/data/helloworld hello=hello_value - - - name: Apply Secrets to AWS Secrets Manager - # this does not do an "install" so assumes the helm chart already exists therefor won't work on init of a helm chart - run: | - echo "${{ steps.vault.outputs.hello_value }}" diff --git a/docker/openshift/Dockerfile.sync-secrets b/docker/openshift/Dockerfile.sync-secrets new file mode 100644 index 00000000..b96de713 --- /dev/null +++ b/docker/openshift/Dockerfile.sync-secrets @@ -0,0 +1,20 @@ +FROM alpine:latest +ARG VAULT_VERSION="1.17.6" +ARG APP_ROOT=/usr/local/bin +ARG SRC=./docker/openshift + +# Install dependencies +RUN apk add --no-cache \ + jq \ + aws-cli + +WORKDIR ${APP_ROOT} + +# Copy the shell script to the container +COPY ${SRC}/sync-secrets.sh ${APP_ROOT}/sync-secrets.sh + +# Ensure shell script has executable permissions +RUN chmod +x ${APP_ROOT}/sync-secrets.sh + +# Command to run the script +CMD [ "./sync-secrets.sh" ] \ No newline at end of file diff --git a/docker/openshift/Dockerfile.update-aws-creds b/docker/openshift/Dockerfile.update-aws-creds new file mode 100644 index 00000000..7c5355da --- /dev/null +++ b/docker/openshift/Dockerfile.update-aws-creds @@ -0,0 +1,32 @@ +FROM alpine:latest +ARG VAULT_VERSION="1.17.6" +ARG APP_ROOT=/usr/local/bin +ARG SRC=./docker/openshift + +# Install dependencies +RUN apk add --no-cache \ + jq \ + aws-cli \ + curl \ + tar \ + bash \ + libc6-compat + +# Download and install the OpenShift CLI (oc) +RUN curl -L https://mirror.openshift.com/pub/openshift-v4/clients/ocp/stable/openshift-client-linux.tar.gz -o /tmp/openshift-client-linux.tar.gz && \ + tar -zxvf /tmp/openshift-client-linux.tar.gz -C /usr/local/bin && \ + rm /tmp/openshift-client-linux.tar.gz + +WORKDIR ${APP_ROOT} + +# Copy the shell script to the container +COPY ${SRC}/update-aws-creds.sh ${APP_ROOT}/update-aws-creds.sh + +# Ensure that shell script and od has executable permissions +RUN chmod +x ${APP_ROOT}/update-aws-creds.sh oc + +# Test if oc is installed correctly +RUN oc version --client + +# Command to run the script +CMD [ "./update-aws-creds.sh" ] \ No newline at end of file diff --git a/docker/openshift/sync-secrets.sh b/docker/openshift/sync-secrets.sh new file mode 100644 index 00000000..dc7e7077 --- /dev/null +++ b/docker/openshift/sync-secrets.sh @@ -0,0 +1,41 @@ +#!/bin/sh + +# Vault details +VAULT_SECRET_ENV="${VAULT_SECRET_ENV}" +LOCAL_SECRET_PATH="${LOCAL_SECRET_PATH}" + +aws_secret_format="external/jasper-X-secret-$VAULT_SECRET_ENV" +secret_keys="\ + aspnet_core \ + auth \ + database \ + file_services_client \ + keycloak \ + location_services_client \ + lookup_services_client \ + misc \ + request \ + splunk \ + user_services_client" + +echo "Syncing secrets..." + +# Iterate on each key to get the value from Vault and save to AWS secrets manager +for key in $secret_keys; do + value=$(jq -r ".${VAULT_SECRET_ENV}_$key" "$LOCAL_SECRET_PATH") + + sanitizedKey=$(echo "$key" | sed "s/_/-/g") + secret_name=$(echo "$aws_secret_format" | sed "s/X/$sanitizedKey/") + secret_string=$(echo "$value" | jq -c '.') + + echo "Uploading $secret_name" + aws secretsmanager put-secret-value \ + --secret-id $secret_name \ + --secret-string "$secret_string" +done + +if [ $? -eq 0 ]; then + echo "Secrets synced successfully from Vault to AWS Secrets Manager." +else + echo "Failed to sync secrets." +fi \ No newline at end of file diff --git a/docker/openshift/update-aws-creds.sh b/docker/openshift/update-aws-creds.sh new file mode 100644 index 00000000..bd32aea4 --- /dev/null +++ b/docker/openshift/update-aws-creds.sh @@ -0,0 +1,29 @@ +#!/bin/sh +ENVIRONMENT="${ENVIRONMENT}" + +# AWS Access Keys/IDs has a scheduled rotation and needs to be kept up-to-date in OpenShift. +# https://developer.gov.bc.ca/docs/default/component/public-cloud-techdocs/design-build-and-deploy-an-application/iam-user-service/#setup-automation-to-retrieve-and-use-keys +echo "Checking if AWS keys needs to be updated..." +param_value=$(aws ssm get-parameter --name "/iam_users/openshiftuser${ENVIRONMENT}_keys" --with-decryption | jq -r '.Parameter.Value') + +if [ $? -eq 0 ]; then + pendingAccessKeyId=$(echo "$param_value" | jq -r '.pending_deletion.AccessKeyID') + pendingSecretAccessKey=$(echo "$param_value" | jq -r '.pending_deletion.SecretAccessKey') + currentAccessKeyId=$(echo "$param_value" | jq -r '.current.AccessKeyID') + currentSecretAccessKey=$(echo "$param_value" | jq -r '.current.SecretAccessKey') + + if [ "$AWS_ACCESS_KEY_ID" = "$pendingAccessKeyId" ] || [ "$AWS_SECRET_ACCESS_KEY" = "$pendingSecretAccessKey" ]; then + echo "Updating AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY..." + + oc create secret generic aws-secret \ + --from-literal=AWS_ACCESS_KEY_ID=$currentAccessKeyId \ + --from-literal=AWS_SECRET_ACCESS_KEY=$currentSecretAccessKey \ + --dry-run=client -o yaml | oc apply -f - + + echo "Done." + else + echo "Credentials are up-to-date." + fi +else + echo "Failed to query credentials from AWS." +fi \ No newline at end of file diff --git a/infrastructure/cloud/environments/dev/dev.tfvars b/infrastructure/cloud/environments/dev/dev.tfvars index 1b6c8ec1..809509aa 100644 --- a/infrastructure/cloud/environments/dev/dev.tfvars +++ b/infrastructure/cloud/environments/dev/dev.tfvars @@ -3,3 +3,5 @@ test_s3_bucket_name = "jasper-test-s3-bucket-dev" web_subnet_names = ["Web_Dev_aza_net", "Web_Dev_azb_net"] app_subnet_names = ["App_Dev_aza_net", "App_Dev_azb_net"] data_subnet_names = ["Data_Dev_aza_net", "Data_Dev_azb_net"] +openshift_iam_user = "openshiftuserdev" +iam_user_table_name = "BCGOV_IAM_USER_TABLE" diff --git a/infrastructure/cloud/environments/dev/variables.tf b/infrastructure/cloud/environments/dev/variables.tf index ee068f95..e44f4cd6 100644 --- a/infrastructure/cloud/environments/dev/variables.tf +++ b/infrastructure/cloud/environments/dev/variables.tf @@ -42,3 +42,13 @@ variable "data_subnet_names" { description = "List of Subnets for Data" type = list(string) } + +variable "openshift_iam_user" { + description = "Openshift IAM Username" + type = string +} + +variable "iam_user_table_name" { + description = "The BCGOV DynamoDb IAM user table" + type = string +} diff --git a/infrastructure/cloud/environments/dev/webapp.tf b/infrastructure/cloud/environments/dev/webapp.tf index c8336e92..49c31d50 100644 --- a/infrastructure/cloud/environments/dev/webapp.tf +++ b/infrastructure/cloud/environments/dev/webapp.tf @@ -6,6 +6,8 @@ module "security" { ecs_web_td_log_group_arn = module.monitoring.ecs_web_td_log_group_arn ecs_api_td_log_group_arn = module.monitoring.ecs_api_td_log_group_arn ecr_repository_arn = module.container.ecr_repository_arn + openshift_iam_user = var.openshift_iam_user + iam_user_table_name = var.iam_user_table_name } module "storage" { @@ -40,6 +42,10 @@ module "container" { ecs_web_td_log_group_name = module.monitoring.ecs_web_td_log_group_name ecs_api_td_log_group_name = module.monitoring.ecs_api_td_log_group_name kms_key_id = module.security.kms_key_id + lb_dns_name = module.networking.lb_dns_name + api_secrets = module.security.api_secrets + web_secrets = module.security.web_secrets + db_secrets = module.security.db_secrets depends_on = [module.monitoring] } diff --git a/infrastructure/cloud/modules/container/ecs.tf b/infrastructure/cloud/modules/container/ecs.tf index 800fad56..85257ce0 100644 --- a/infrastructure/cloud/modules/container/ecs.tf +++ b/infrastructure/cloud/modules/container/ecs.tf @@ -38,6 +38,12 @@ resource "aws_ecs_task_definition" "ecs_web_task_definition" { "awslogs-stream-prefix" = "ecs" } } + secrets = [ + for secret in var.web_secrets : { + name = secret[0] + valueFrom = secret[1] + } + ] } ]) } @@ -81,6 +87,18 @@ resource "aws_ecs_task_definition" "ecs_api_task_definition" { containerPort = 5000 } ] + environment = [ + { + name = "CORS_DOMAIN" + value = var.lb_dns_name + } + ] + secrets = [ + for secret in var.api_secrets : { + name = secret[0] + valueFrom = secret[1] + } + ] logConfiguration = { logDriver = "awslogs" options = { diff --git a/infrastructure/cloud/modules/container/variables.tf b/infrastructure/cloud/modules/container/variables.tf index 4edc75c4..08b35334 100644 --- a/infrastructure/cloud/modules/container/variables.tf +++ b/infrastructure/cloud/modules/container/variables.tf @@ -48,3 +48,22 @@ variable "kms_key_id" { type = string } +variable "lb_dns_name" { + description = "Load Balancer DNS Name" + type = string +} + +variable "api_secrets" { + description = "List if env variable secrets used in API" + type = list(list(string)) +} + +variable "web_secrets" { + description = "List if env variable secrets used in Web" + type = list(list(string)) +} + +variable "db_secrets" { + description = "List if env variable secrets used in Database" + type = list(list(string)) +} diff --git a/infrastructure/cloud/modules/networking/outputs.tf b/infrastructure/cloud/modules/networking/outputs.tf index 5c25d098..4f22e94c 100644 --- a/infrastructure/cloud/modules/networking/outputs.tf +++ b/infrastructure/cloud/modules/networking/outputs.tf @@ -9,3 +9,7 @@ output "ecs_sg_id" { output "web_subnets_ids" { value = local.web_subnets } + +output "lb_dns_name" { + value = aws_lb.lb.dns_name +} diff --git a/infrastructure/cloud/modules/security/acmpca.tf b/infrastructure/cloud/modules/security/acmpca.tf new file mode 100644 index 00000000..c673248e --- /dev/null +++ b/infrastructure/cloud/modules/security/acmpca.tf @@ -0,0 +1,46 @@ +# resource "aws_acmpca_certificate_authority" "acmpca_ca" { +# type = "ROOT" +# usage_mode = "GENERAL_PURPOSE" +# key_storage_security_standard = "FIPS_140_2_LEVEL_3_OR_HIGHER" +# certificate_authority_configuration { +# key_algorithm = "RSA_2048" +# signing_algorithm = "SHA256WITHRSA" +# subject { +# country = "CA" +# organization = "bcgov" +# organizational_unit = "bccourts" +# distinguished_name_qualifier = "${var.app_name}-ca-${var.environment}" +# common_name = "${var.app_name}-ca-${var.environment}" +# state = "BC" +# locality = "Vancouver" +# } +# } + +# tags = { +# Name = "${var.app_name}-acmpca-${var.environment}" +# } +# } + +# resource "aws_acmpca_permission" "acmpca_permission" { +# certificate_authority_arn = aws_acmpca_certificate_authority.acmpca_ca.arn +# actions = ["IssueCertificate", "GetCertificate", "ListPermissions"] +# principal = "acm.amazonaws.com" +# } + +# resource "aws_acmpca_certificate" "acmpca_certificate" { +# certificate_authority_arn = aws_acmpca_certificate_authority.acmpca_ca.arn +# certificate_signing_request = aws_acmpca_certificate_authority.acmpca_ca.certificate_signing_request +# signing_algorithm = "SHA256WITHRSA" +# template_arn = "arn:aws:acm-pca:::template/RootCACertificate/V1" +# validity { +# type = "YEARS" +# value = 3 +# } +# } + +# resource "aws_acmpca_certificate_authority_certificate" "acmpca_cac" { +# certificate_authority_arn = aws_acmpca_certificate_authority.acmpca_ca.arn + +# certificate = aws_acmpca_certificate.acmpca_certificate.certificate +# certificate_chain = aws_acmpca_certificate.acmpca_certificate.certificate_chain +# } diff --git a/infrastructure/cloud/modules/security/iam.tf b/infrastructure/cloud/modules/security/iam.tf index cde09d8a..19ecc188 100644 --- a/infrastructure/cloud/modules/security/iam.tf +++ b/infrastructure/cloud/modules/security/iam.tf @@ -1,3 +1,6 @@ +# +# ECS +# resource "aws_iam_role" "ecs_execution_role" { name = "${var.app_name}-ecs-execution-role-${var.environment}" @@ -59,7 +62,184 @@ resource "aws_iam_role_policy" "ecs_execution_policy" { "${var.ecs_web_td_log_group_arn}:*", "${var.ecs_api_td_log_group_arn}:*" ] + }, + { + Action = [ + "secretsmanager:GetSecretValue" + ], + Effect = "Allow", + Resource = [ + aws_secretsmanager_secret.aspnet_core_secret.arn, + aws_secretsmanager_secret.file_services_client_secret.arn, + aws_secretsmanager_secret.location_services_client_secret.arn, + aws_secretsmanager_secret.lookup_services_client_secret.arn, + aws_secretsmanager_secret.user_services_client_secret.arn, + aws_secretsmanager_secret.keycloak_secret.arn, + aws_secretsmanager_secret.request_secret.arn, + aws_secretsmanager_secret.splunk_secret.arn, + aws_secretsmanager_secret.database_secret.arn, + aws_secretsmanager_secret.aspnet_core_secret.arn, + aws_secretsmanager_secret.misc_secret.arn, + aws_secretsmanager_secret.auth_secret.arn + ] + }, + { + Action = [ + "kms:Decrypt" + ], + Effect = "Allow", + Resource = aws_kms_key.kms_key.arn + } + ] + }) +} + +# +# RolesAnywhere +# +# resource "aws_iam_role" "rolesanywhere_role" { +# name = "${var.app_name}-rolesanywhere-role-${var.environment}" + +# assume_role_policy = jsonencode({ +# Version = "2012-10-17" +# Statement = [{ +# Action = [ +# "sts:AssumeRole", +# "sts:TagSession", +# "sts:SetSourceIdentity" +# ] +# Principal = { +# Service = "rolesanywhere.amazonaws.com" +# } +# Effect = "Allow" +# }] +# }) +# } + +# Openshift +# https://developer.gov.bc.ca/docs/default/component/public-cloud-techdocs/design-build-and-deploy-an-application/iam-user-service/ +# Step 1: Add opeshiftuser if not exist +data "aws_dynamodb_table" "iam_user_table" { + name = var.iam_user_table_name +} + +resource "null_resource" "check_and_insert_openshiftuser_record" { + triggers = { + always_run = timestamp() + } + + provisioner "local-exec" { + command = <