diff --git a/.secrets.baseline b/.secrets.baseline index 49a70104..385c0b04 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -3,7 +3,7 @@ "files": "^.secrets.baseline$|^.*pgsslrootcert.yml$", "lines": null }, - "generated_at": "2019-12-03T19:44:47Z", + "generated_at": "2019-12-05T17:54:05Z", "plugins_used": [ { "base64_limit": 4.5, @@ -98,7 +98,7 @@ "hashed_secret": "afc848c316af1a89d49826c5ae9d00ed769415f3", "is_secret": false, "is_verified": false, - "line_number": 21, + "line_number": 29, "type": "Secret Keyword" } ], diff --git a/README.md b/README.md index 44749862..9c31fc19 100644 --- a/README.md +++ b/README.md @@ -168,12 +168,7 @@ Testing file uploads and downloads locally requires a few configuration options. In the flask config (`config/base.ini`, perhaps): ``` -CSP= - -AWS_REGION_NAME="" -AWS_ACCESS_KEY="" -AWS_SECRET_KEY="" -AWS_BUCKET_NAME="" +CSP=< azure | mock> AZURE_STORAGE_KEY="" AZURE_ACCOUNT_NAME="" @@ -183,7 +178,7 @@ AZURE_TO_BUCKET_NAME="" There are also some build-time configuration that are used by parcel. Add these to `.env.local`, and run `rm -r .cache/` before running `yarn build`: ``` -CLOUD_PROVIDER= +CLOUD_PROVIDER= AZURE_ACCOUNT_NAME="" AZURE_CONTAINER_NAME="" ``` @@ -223,6 +218,9 @@ To generate coverage reports for the Javascript tests: ## Configuration - `ASSETS_URL`: URL to host which serves static assets (such as a CDN). +- `AZURE_ACCOUNT_NAME`: The name for the Azure blob storage account +- `AZURE_STORAGE_KEY`: A valid secret key for the Azure blob storage account +- `AZURE_TO_BUCKET_NAME`: The Azure blob storage container name for task order uploads - `BLOB_STORAGE_URL`: URL to Azure blob storage container. - `CAC_URL`: URL for the CAC authentication route. - `CA_CHAIN`: Path to the CA chain file. @@ -238,6 +236,11 @@ To generate coverage reports for the Javascript tests: - `ENVIRONMENT`: String specifying the current environment. Acceptable values: "dev", "prod". - `LIMIT_CONCURRENT_SESSIONS`: Boolean specifying if users should be allowed only one active session at a time. - `LOG_JSON`: Boolean specifying whether app should log in a json format. +- `MAIL_PASSWORD`: String. Password for the SMTP server. +- `MAIL_PORT`: Integer. Port to use on the SMTP server. +- `MAIL_SENDER`: String. Email address to send outgoing mail from. +- `MAIL_SERVER`: The SMTP host +- `MAIL_TLS`: Boolean. Use TLS to connect to the SMTP server. - `PERMANENT_SESSION_LIFETIME`: Integer specifying how many seconds a user's session can stay valid for. https://flask.palletsprojects.com/en/1.1.x/config/#PERMANENT_SESSION_LIFETIME - `PGDATABASE`: String specifying the name of the postgres database. - `PGHOST`: String specifying the hostname of the postgres database. diff --git a/atst/app.py b/atst/app.py index 1b60f64c..e9daabd6 100644 --- a/atst/app.py +++ b/atst/app.py @@ -200,23 +200,21 @@ def make_config(direct_config=None): ENV_CONFIG_FILENAME = os.path.join( os.path.dirname(__file__), "../config/", "{}.ini".format(ENV.lower()) ) - OVERRIDE_CONFIG_FILENAME = os.getenv("OVERRIDE_CONFIG_FULLPATH") + OVERRIDE_CONFIG_DIRECTORY = os.getenv("OVERRIDE_CONFIG_DIRECTORY") config = ConfigParser(allow_no_value=True) config.optionxform = str config_files = [BASE_CONFIG_FILENAME, ENV_CONFIG_FILENAME] - if OVERRIDE_CONFIG_FILENAME: - config_files.append(OVERRIDE_CONFIG_FILENAME) # ENV_CONFIG will override values in BASE_CONFIG. config.read(config_files) + if OVERRIDE_CONFIG_DIRECTORY: + apply_config_from_directory(OVERRIDE_CONFIG_DIRECTORY, config) + # Check for ENV variables as a final source of overrides - for confsetting in config.options("default"): - env_override = os.getenv(confsetting.upper()) - if env_override: - config.set("default", confsetting, env_override) + apply_config_from_environment(config) # override if a dictionary of options has been given if direct_config: @@ -244,6 +242,36 @@ def make_config(direct_config=None): return map_config(config) +def apply_config_from_directory(config_dir, config, section="default"): + """ + Loop files in a directory, check if the names correspond to + known config values, and apply the file contents as the value + for that setting if they do. + """ + for confsetting in os.listdir(config_dir): + if confsetting in config.options(section): + full_path = os.path.join(config_dir, confsetting) + with open(full_path, "r") as conf_file: + config.set(section, confsetting, conf_file.read().strip()) + + return config + + +def apply_config_from_environment(config, section="default"): + """ + Loops all the configuration settins in a given section of a + config object and checks whether those settings also exist as + environment variables. If so, it applies the environment + variables value as the new configuration setting value. + """ + for confsetting in config.options(section): + env_override = os.getenv(confsetting.upper()) + if env_override: + config.set(section, confsetting, env_override) + + return config + + def make_redis(app, config): r = redis.Redis.from_url(config["REDIS_URI"]) app.redis = r diff --git a/config/base.ini b/config/base.ini index ade3abe1..2cc8fd93 100644 --- a/config/base.ini +++ b/config/base.ini @@ -1,5 +1,8 @@ [default] ASSETS_URL +AZURE_ACCOUNT_NAME +AZURE_STORAGE_KEY +AZURE_TO_BUCKET_NAME BLOB_STORAGE_URL=http://localhost:8000/ CAC_URL = http://localhost:8000/login-redirect CA_CHAIN = ssl/server-certs/ca-chain.pem @@ -15,6 +18,11 @@ DISABLE_CRL_CHECK = false ENVIRONMENT = dev LIMIT_CONCURRENT_SESSIONS = false LOG_JSON = false +MAIL_PASSWORD +MAIL_PORT +MAIL_SENDER +MAIL_SERVER +MAIL_TLS PERMANENT_SESSION_LIFETIME = 1800 PGDATABASE = atat PGHOST = localhost diff --git a/deploy/README.md b/deploy/README.md index 25380293..c0683ae0 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -37,35 +37,6 @@ If you are satisfied with the output from the diff, you can apply the new config ## Secrets and Configuration -### atst-overrides.ini - -Production configuration values are provided to the ATAT Flask app by writing an `atst-overrides.ini` file to the running Docker container. This file is stored as a Kubernetes secret. It contains configuration information for the database connection, mailer, etc. - -To update the configuration, you can do the following: - -``` -kubectl -n atat get secret atst-config-ini -o=jsonpath='{.data.override\.ini}' | base64 --decode > override.ini -``` - -This base64 decodes the secret and writes it to a local file called `override.ini`. Make any necessary config changes to that file. - -To apply the new config, first delete the existing copy of the secret: - -``` -kubectl -n atat delete secret atst-config-ini -``` - -Then create a new copy of the secret from your updated copy: - -``` -kubectl -n atat create secret generic atst-config-ini --from-file=./override.ini -``` - -Notes: - -- Be careful not to check the override.ini file into source control. -- Be careful not to overwrite one CSP cluster's config with the other's. This will break everything. - ### nginx-htpasswd If the site is running in dev mode, the `/login-dev` endpoint is available. This endpoint is protected by basic HTTP auth. To create a new password file, run: @@ -178,11 +149,32 @@ az keyvault secret set --vault-name --name --value ``` --- +# Secrets Management + +Secrets, keys, and certificates are managed from Azure Key Vault. These items are mounted into the containers at runtime using the FlexVol implementation described below. + +The following are mounted into the NGINX container in the atst pod: + +- The TLS certs for the site +- The DH parameter for TLS connections + +These are mounted into every instance of the Flask application container (the atst container, the celery worker, etc.): + +- The Azure storage key used to access blob storage (AZURE_STORAGE_KEY) +- The password for the SMTP server used to send mail (MAIL_PASSWORD) +- The Postgres database user password (PGPASSWORD) +- The Redis user password (REDIS_PASSWORD) +- The Flask secret key used for session signing and generating CSRF tokens (SECRET_KEY) + +Secrets should be added to Key Vault with the following naming pattern: [branch/environment]-[all-caps config setting name]. Note that Key Vault does not support underscores. Substitute hyphens. For example, the config setting for the SMTP server password is MAIL_SERVER. The corresponding secret name in Key Vault is "master-MAIL-SERVER" for the credential used in the primary environment.These secrets are mounted into the containers via FlexVol. + +To add or manage secrets, keys, and certificates in Key Vault, see the [documentation](https://docs.microsoft.com/en-us/azure/key-vault/quick-create-cli). + # Setting Up FlexVol for Secrets ## Preparing Azure Environment -A Key Vault will need to be created. Save it's full id (the full path) for use later. +A Key Vault will need to be created. Save its full id (the full path) for use later. ## Preparing Cluster diff --git a/deploy/azure/atst-envvars-configmap.yml b/deploy/azure/atst-envvars-configmap.yml index d6bd60ef..8907493d 100644 --- a/deploy/azure/atst-envvars-configmap.yml +++ b/deploy/azure/atst-envvars-configmap.yml @@ -6,15 +6,28 @@ metadata: namespace: atat data: ASSETS_URL: https://atat-cdn.azureedge.net/ + AZURE_ACCOUNT_NAME: atat + AZURE_TO_BUCKET_NAME: task-order-pdfs BLOB_STORAGE_URL: https://atat.blob.core.windows.net/ + CAC_URL: https://auth-staging.atat.code.mil/login-redirect CDN_ORIGIN: https://azure.atat.code.mil CELERY_DEFAULT_QUEUE: celery-master CSP: azure + DEBUG: 0 FLASK_ENV: master LOG_JSON: "true" - OVERRIDE_CONFIG_FULLPATH: /opt/atat/atst/atst-overrides.ini + MAIL_PORT: 587 + MAIL_SENDER: postmaster@atat.code.mil + MAIL_SERVER: smtp.mailgun.org + MAIL_TLS: "true" + OVERRIDE_CONFIG_DIRECTORY: /config + PGAPPNAME: atst + PGDATABASE: staging + PGHOST: atat-db.postgres.database.azure.com + PGPORT: 5432 PGSSLMODE: verify-full PGSSLROOTCERT: /opt/atat/atst/ssl/pgsslrootcert.crt + PGUSER: atat_master@atat-db REDIS_HOST: atat.redis.cache.windows.net:6380 REDIS_TLS: "true" STATIC_URL: https://atat-cdn.azureedge.net/static/ diff --git a/deploy/azure/atst-worker-envvars-configmap.yml b/deploy/azure/atst-worker-envvars-configmap.yml index c3522f70..ab10c118 100644 --- a/deploy/azure/atst-worker-envvars-configmap.yml +++ b/deploy/azure/atst-worker-envvars-configmap.yml @@ -5,9 +5,25 @@ metadata: name: atst-worker-envvars namespace: atat data: + AZURE_ACCOUNT_NAME: atat + AZURE_TO_BUCKET_NAME: task-order-pdfs + CAC_URL: https://auth-staging.atat.code.mil/login-redirect CELERY_DEFAULT_QUEUE: celery-master - DISABLE_CRL_CHECK: "True" + DEBUG: 0 + DISABLE_CRL_CHECK: "true" + MAIL_PORT: 587 + MAIL_SENDER: postmaster@atat.code.mil + MAIL_SERVER: smtp.mailgun.org + MAIL_TLS: "true" + OVERRIDE_CONFIG_DIRECTORY: /config + PGAPPNAME: atst + PGDATABASE: staging + PGHOST: atat-db.postgres.database.azure.com + PGPORT: 5432 PGSSLMODE: verify-full PGSSLROOTCERT: /opt/atat/atst/ssl/pgsslrootcert.crt + PGUSER: atat_master@atat-db + REDIS_HOST: atat.redis.cache.windows.net:6380 + REDIS_TLS: "true" SERVER_NAME: azure.atat.code.mil TZ: UTC diff --git a/deploy/azure/azure.yml b/deploy/azure/azure.yml index 02952029..8fe7fd87 100644 --- a/deploy/azure/azure.yml +++ b/deploy/azure/azure.yml @@ -34,9 +34,6 @@ spec: - configMapRef: name: atst-envvars volumeMounts: - - name: atst-config - mountPath: "/opt/atat/atst/atst-overrides.ini" - subPath: atst-overrides.ini - name: nginx-client-ca-bundle mountPath: "/opt/atat/atst/ssl/server-certs/ca-chain.pem" subPath: client-ca-bundle.pem @@ -50,6 +47,8 @@ spec: - name: uwsgi-config mountPath: "/opt/atat/atst/uwsgi.ini" subPath: uwsgi.ini + - name: flask-secret + mountPath: "/config" - name: nginx image: nginx:alpine ports: @@ -79,13 +78,6 @@ spec: - name: nginx-secret mountPath: "/etc/ssl/" volumes: - - name: atst-config - secret: - secretName: atst-config-ini - items: - - key: override.ini - path: atst-overrides.ini - mode: 0644 - name: nginx-client-ca-bundle configMap: name: nginx-client-ca-bundle @@ -141,6 +133,16 @@ spec: keyvaultobjectaliases: "dhparam.pem;atat.key;atat.crt" keyvaultobjecttypes: "secret;secret;secret" tenantid: $TENANT_ID + - name: flask-secret + flexVolume: + driver: "azure/kv" + options: + usepodidentity: "true" + keyvaultname: "atat-vault-test" + keyvaultobjectnames: "master-AZURE-STORAGE-KEY;master-MAIL-PASSWORD;master-PGPASSWORD;master-REDIS-PASSWORD;master-SECRET-KEY" + keyvaultobjectaliases: "AZURE_STORAGE_KEY;MAIL_PASSWORD;PGPASSWORD;REDIS_PASSWORD;SECRET_KEY" + keyvaultobjecttypes: "secret;secret;secret;secret;key" + tenantid: $TENANT_ID --- apiVersion: extensions/v1beta1 kind: Deployment @@ -161,6 +163,7 @@ spec: labels: app: atst role: worker + aadpodidbinding: atat-kv-id-binding spec: securityContext: fsGroup: 101 @@ -182,20 +185,12 @@ spec: - configMapRef: name: atst-worker-envvars volumeMounts: - - name: atst-config - mountPath: "/opt/atat/atst/atst-overrides.ini" - subPath: atst-overrides.ini - name: pgsslrootcert mountPath: "/opt/atat/atst/ssl/pgsslrootcert.crt" subPath: pgsslrootcert.crt + - name: flask-secret + mountPath: "/config" volumes: - - name: atst-config - secret: - secretName: atst-config-ini - items: - - key: override.ini - path: atst-overrides.ini - mode: 0644 - name: pgsslrootcert configMap: name: pgsslrootcert @@ -203,6 +198,16 @@ spec: - key: cert path: pgsslrootcert.crt mode: 0666 + - name: flask-secret + flexVolume: + driver: "azure/kv" + options: + usepodidentity: "true" + keyvaultname: "atat-vault-test" + keyvaultobjectnames: "master-AZURE-STORAGE-KEY;master-MAIL-PASSWORD;master-PGPASSWORD;master-REDIS-PASSWORD;master-SECRET-KEY" + keyvaultobjectaliases: "AZURE_STORAGE_KEY;MAIL_PASSWORD;PGPASSWORD;REDIS_PASSWORD;SECRET_KEY" + keyvaultobjecttypes: "secret;secret;secret;secret;key" + tenantid: $TENANT_ID --- apiVersion: extensions/v1beta1 kind: Deployment @@ -223,6 +228,7 @@ spec: labels: app: atst role: beat + aadpodidbinding: atat-kv-id-binding spec: securityContext: fsGroup: 101 @@ -244,20 +250,12 @@ spec: - configMapRef: name: atst-worker-envvars volumeMounts: - - name: atst-config - mountPath: "/opt/atat/atst/atst-overrides.ini" - subPath: atst-overrides.ini - name: pgsslrootcert mountPath: "/opt/atat/atst/ssl/pgsslrootcert.crt" subPath: pgsslrootcert.crt + - name: flask-secret + mountPath: "/config" volumes: - - name: atst-config - secret: - secretName: atst-config-ini - items: - - key: override.ini - path: atst-overrides.ini - mode: 0644 - name: pgsslrootcert configMap: name: pgsslrootcert @@ -265,6 +263,16 @@ spec: - key: cert path: pgsslrootcert.crt mode: 0666 + - name: flask-secret + flexVolume: + driver: "azure/kv" + options: + usepodidentity: "true" + keyvaultname: "atat-vault-test" + keyvaultobjectnames: "master-AZURE-STORAGE-KEY;master-MAIL-PASSWORD;master-PGPASSWORD;master-REDIS-PASSWORD;master-SECRET-KEY" + keyvaultobjectaliases: "AZURE_STORAGE_KEY;MAIL_PASSWORD;PGPASSWORD;REDIS_PASSWORD;SECRET_KEY" + keyvaultobjecttypes: "secret;secret;secret;secret;key" + tenantid: $TENANT_ID --- apiVersion: v1 kind: Service diff --git a/deploy/azure/crls-sync.yaml b/deploy/azure/crls-sync.yaml index 5e95e331..5fdcd7b8 100644 --- a/deploy/azure/crls-sync.yaml +++ b/deploy/azure/crls-sync.yaml @@ -10,6 +10,11 @@ spec: jobTemplate: spec: template: + metadata: + labels: + app: atst + role: crl-sync + aadpodidbinding: atat-kv-id-binding spec: restartPolicy: OnFailure containers: @@ -27,19 +32,21 @@ spec: - configMapRef: name: atst-worker-envvars volumeMounts: - - name: atst-config - mountPath: "/opt/atat/atst/atst-overrides.ini" - subPath: atst-overrides.ini - name: crls-vol mountPath: "/opt/atat/atst/crls" + - name: flask-secret + mountPath: "/config" volumes: - - name: atst-config - secret: - secretName: atst-config-ini - items: - - key: override.ini - path: atst-overrides.ini - mode: 0644 - name: crls-vol persistentVolumeClaim: claimName: crls-vol-claim + - name: flask-secret + flexVolume: + driver: "azure/kv" + options: + usepodidentity: "true" + keyvaultname: "atat-vault-test" + keyvaultobjectnames: "master-AZURE-STORAGE-KEY;master-MAIL-PASSWORD;master-PGPASSWORD;master-REDIS-PASSWORD;master-SECRET-KEY" + keyvaultobjectaliases: "AZURE_STORAGE_KEY;MAIL_PASSWORD;PGPASSWORD;REDIS_PASSWORD;SECRET_KEY" + keyvaultobjecttypes: "secret;secret;secret;secret;key" + tenantid: $TENANT_ID diff --git a/deploy/overlays/staging/flex_vol.yml b/deploy/overlays/staging/flex_vol.yml index 0ebeea84..0efa4044 100644 --- a/deploy/overlays/staging/flex_vol.yml +++ b/deploy/overlays/staging/flex_vol.yml @@ -11,3 +11,52 @@ spec: options: keyvaultname: "atat-vault-test" keyvaultobjectnames: "dhparam4096;staging-cert;staging-cert" + - name: flask-secret + flexVolume: + options: + keyvaultname: "atat-vault-test" + keyvaultobjectnames: "staging-AZURE-STORAGE-KEY;staging-MAIL-PASSWORD;staging-PGPASSWORD;staging-REDIS-PASSWORD;staging-SECRET-KEY" +--- +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: atst-worker +spec: + template: + spec: + volumes: + - name: flask-secret + flexVolume: + options: + keyvaultname: "atat-vault-test" + keyvaultobjectnames: "staging-AZURE-STORAGE-KEY;staging-MAIL-PASSWORD;staging-PGPASSWORD;staging-REDIS-PASSWORD;staging-SECRET-KEY" +--- +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: atst-beat +spec: + template: + spec: + volumes: + - name: flask-secret + flexVolume: + options: + keyvaultname: "atat-vault-test" + keyvaultobjectnames: "staging-AZURE-STORAGE-KEY;staging-MAIL-PASSWORD;staging-PGPASSWORD;staging-REDIS-PASSWORD;staging-SECRET-KEY" +--- +apiVersion: batch/v1beta1 +kind: CronJob +metadata: + name: crls +spec: + jobTemplate: + spec: + template: + spec: + volumes: + - name: flask-secret + flexVolume: + options: + keyvaultname: "atat-vault-test" + keyvaultobjectnames: "staging-AZURE-STORAGE-KEY;staging-MAIL-PASSWORD;staging-PGPASSWORD;staging-REDIS-PASSWORD;staging-SECRET-KEY" diff --git a/deploy/shared/migration.yaml b/deploy/shared/migration.yaml index d571c84d..b5161114 100644 --- a/deploy/shared/migration.yaml +++ b/deploy/shared/migration.yaml @@ -7,6 +7,11 @@ spec: ttlSecondsAfterFinished: 100 backoffLimit: 2 template: + metadata: + labels: + app: atst + role: migration + aadpodidbinding: atat-kv-id-binding spec: containers: - name: migration @@ -28,20 +33,12 @@ spec: - configMapRef: name: atst-worker-envvars volumeMounts: - - name: atst-config - mountPath: "/opt/atat/atst/atst-overrides.ini" - subPath: atst-overrides.ini - name: pgsslrootcert mountPath: "/opt/atat/atst/ssl/pgsslrootcert.crt" subPath: pgsslrootcert.crt + - name: flask-secret + mountPath: "/config" volumes: - - name: atst-config - secret: - secretName: atst-config-ini - items: - - key: override.ini - path: atst-overrides.ini - mode: 0644 - name: pgsslrootcert configMap: name: pgsslrootcert @@ -49,4 +46,14 @@ spec: - key: cert path: pgsslrootcert.crt mode: 0666 + - name: flask-secret + flexVolume: + driver: "azure/kv" + options: + usepodidentity: "true" + keyvaultname: "atat-vault-test" + keyvaultobjectnames: "master-AZURE-STORAGE-KEY;master-MAIL-PASSWORD;master-PGPASSWORD;master-REDIS-PASSWORD;master-SECRET-KEY" + keyvaultobjectaliases: "AZURE_STORAGE_KEY;MAIL_PASSWORD;PGPASSWORD;REDIS_PASSWORD;SECRET_KEY" + keyvaultobjecttypes: "secret;secret;secret;secret;key" + tenantid: $TENANT_ID restartPolicy: Never diff --git a/tests/test_app.py b/tests/test_app.py index 222f4a4f..937a15e2 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,8 +1,13 @@ import os +from configparser import ConfigParser import pytest -from atst.app import make_crl_validator +from atst.app import ( + make_crl_validator, + apply_config_from_directory, + apply_config_from_environment, +) @pytest.fixture @@ -22,3 +27,43 @@ def test_make_crl_validator_creates_crl_dir(app, tmpdir, replace_crl_dir_config) replace_crl_dir_config(crl_dir) make_crl_validator(app) assert os.path.isdir(crl_dir) + + +@pytest.fixture +def config_object(): + config = ConfigParser() + config.optionxform = str + config.read_string("[default]\nFOO=BALONEY") + return config + + +def test_apply_config_from_directory(tmpdir, config_object): + config_setting = tmpdir.join("FOO") + with open(config_setting, "w") as conf_file: + conf_file.write("MAYO") + + apply_config_from_directory(tmpdir, config_object) + assert config_object.get("default", "FOO") == "MAYO" + + +def test_apply_config_from_directory_skips_unknown_settings(tmpdir, config_object): + config_setting = tmpdir.join("FLARF") + with open(config_setting, "w") as conf_file: + conf_file.write("MAYO") + + apply_config_from_directory(tmpdir, config_object) + assert "FLARF" not in config_object.options("default") + + +def test_apply_config_from_environment(monkeypatch, config_object): + monkeypatch.setenv("FOO", "MAYO") + apply_config_from_environment(config_object) + assert config_object.get("default", "FOO") == "MAYO" + + +def test_apply_config_from_environment_skips_unknown_settings( + monkeypatch, config_object +): + monkeypatch.setenv("FLARF", "MAYO") + apply_config_from_environment(config_object) + assert "FLARF" not in config_object.options("default")